From e91332e98e81a568056be0a71e36f1431ade9bcb Mon Sep 17 00:00:00 2001 From: Dieter Blomme Date: Sun, 26 Oct 2025 02:52:36 +0100 Subject: [PATCH 01/16] Add filtering and sorting for custom fields --- client/src/components/column.tsx | 47 +++- client/src/components/dataProvider.ts | 21 ++ client/src/utils/filtering.ts | 81 ++++++- client/src/utils/queryFields.ts | 18 ++ client/src/utils/sorting.ts | 37 ++- spoolman/api/v1/filament.py | 12 +- spoolman/api/v1/spool.py | 12 +- spoolman/api/v1/vendor.py | 12 +- spoolman/database/filament.py | 72 +++++- spoolman/database/spool.py | 118 +++++++--- spoolman/database/utils.py | 222 +++++++++++++++++- spoolman/database/vendor.py | 62 ++++- .../tests/fields/test_filter_sort.py | 176 ++++++++++++++ 13 files changed, 821 insertions(+), 69 deletions(-) create mode 100644 tests_integration/tests/fields/test_filter_sort.py diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index 059b607f0..7f5103048 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -40,7 +40,7 @@ export interface Action { interface BaseColumnProps { id: string | string[]; - dataId?: keyof Obj & string; + dataId?: keyof Obj & string | string; // Allow string values for custom fields i18ncat?: string; i18nkey?: string; title?: string; @@ -389,13 +389,56 @@ export function NumberRangeColumn(props: NumberColumnProps { + filters.push({ + text: choice, + value: `"${choice}"`, // Exact match + }); + }); + } + + // For boolean fields, add true/false options + if (field.field_type === FieldType.boolean) { + filters.push( + { text: "Yes", value: "true" }, + { text: "No", value: "false" } + ); + } + + // Add empty option for all field types + filters.push({ + text: "", + value: "", + }); + + return filters; +} + export function CustomFieldColumn(props: Omit, "id"> & { field: Field }) { const field = props.field; + const fieldId = `extra.${field.key}`; + + // Get filtered values for this field + const typedFilters = typeFilters(props.tableState.filters); + const filteredValue = getFiltersForField(typedFilters, fieldId); + + // Create filters based on field type + const filters = createCustomFieldFilters(field); + const commonProps = { ...props, id: ["extra", field.key], title: field.name, - sorter: false, + sorter: true, // Enable sorting for custom fields + dataId: fieldId, // Set the dataId for sorting + filters: filters, // Add filters + filteredValue: filteredValue, // Set filtered values transform: (value: unknown) => { if (value === null || value === undefined) { return undefined; diff --git a/client/src/components/dataProvider.ts b/client/src/components/dataProvider.ts index 95efd97a9..d2c439af5 100644 --- a/client/src/components/dataProvider.ts +++ b/client/src/components/dataProvider.ts @@ -2,6 +2,9 @@ import { DataProvider } from "@refinedev/core"; import { axiosInstance } from "@refinedev/simple-rest"; import { AxiosInstance } from "axios"; import { stringify } from "query-string"; +import { getCustomFieldFilters } from "../utils/filtering"; +import { isCustomField } from "../utils/queryFields"; +import { getCustomFieldSorters, isCustomFieldSorter } from "../utils/sorting"; type MethodTypes = "get" | "delete" | "head" | "options"; type MethodTypesWithBody = "post" | "put" | "patch"; @@ -25,20 +28,30 @@ const dataProvider = ( } if (sorters && sorters.length > 0) { + // Map all sorters, including custom field sorters queryParams["sort"] = sorters .map((sort) => { const field = sort.field; + // Custom field sorters are already in the correct format (extra.field_key) return `${field}:${sort.order}`; }) .join(","); } if (filters && filters.length > 0) { + // Process regular filters filters.forEach((filter) => { if (!("field" in filter)) { throw Error("Filter must be a LogicalFilter."); } + const field = filter.field; + + // Skip custom fields, they'll be handled separately + if (typeof field === 'string' && isCustomField(field)) { + return; + } + if (filter.value.length > 0) { const filterValueArray = Array.isArray(filter.value) ? filter.value : [filter.value]; @@ -54,6 +67,14 @@ const dataProvider = ( queryParams[field] = filterValue; } }); + + // Process custom field filters + const customFieldFilters = getCustomFieldFilters(filters); + Object.entries(customFieldFilters).forEach(([key, values]) => { + if (values.length > 0) { + queryParams[`extra.${key}`] = values.join(","); + } + }); } const { data, headers } = await httpClient[requestMethod](`${url}`, { diff --git a/client/src/utils/filtering.ts b/client/src/utils/filtering.ts index da99b43c4..177ab96a7 100644 --- a/client/src/utils/filtering.ts +++ b/client/src/utils/filtering.ts @@ -1,7 +1,8 @@ import { CrudFilter, CrudOperators } from "@refinedev/core"; +import { Field, FieldType, getCustomFieldKey, isCustomField } from "./queryFields"; interface TypedCrudFilter { - field: keyof Obj; + field: keyof Obj | string; operator: Exclude; value: string[]; } @@ -16,9 +17,9 @@ export function typeFilters(filters: CrudFilter[]): TypedCrudFilter[] * @param field The field to get the filter values for. * @returns An array of filter values for the given field. */ -export function getFiltersForField( +export function getFiltersForField( filters: TypedCrudFilter[], - field: Field, + field: Field | string, ): string[] { const filterValues: string[] = []; filters.forEach((filter) => { @@ -29,6 +30,80 @@ export function getFiltersForField( return filterValues; } +/** + * Creates a filter value for a custom field based on its type + * @param field The custom field definition + * @param value The value to filter by + * @returns The formatted filter value + */ +export function formatCustomFieldFilterValue(field: Field, value: any): string { + switch (field.field_type) { + case FieldType.text: + case FieldType.choice: + // For text and choice fields, we can use the value directly + // If it's an exact match, surround with quotes + if (typeof value === "string" && !value.startsWith('"') && !value.endsWith('"')) { + // Check if we need an exact match (no wildcards) + if (!value.includes("*") && !value.includes("?")) { + return `"${value}"`; + } + } + return value; + + case FieldType.integer: + case FieldType.float: + // For numeric fields, we can use the value directly + return value.toString(); + + case FieldType.boolean: + // For boolean fields, convert to "true" or "false" + return value ? "true" : "false"; + + case FieldType.datetime: + // For datetime fields, format as ISO string + if (value instanceof Date) { + return value.toISOString(); + } + return value; + + case FieldType.integer_range: + case FieldType.float_range: + // For range fields, format as min:max + if (Array.isArray(value) && value.length === 2) { + return `${value[0] ?? ""}:${value[1] ?? ""}`; + } + return value; + + default: + return value; + } +} + +/** + * Extracts all custom field filters from a list of filters + * @param filters The list of filters + * @returns An object with custom field keys and their filter values + */ +export function getCustomFieldFilters( + filters: CrudFilter[] | TypedCrudFilter[] +): Record { + const customFieldFilters: Record = {}; + + filters.forEach((filter) => { + if (!("field" in filter)) { + return; // Skip non-field filters + } + + const field = filter.field.toString(); + if (isCustomField(field)) { + const key = getCustomFieldKey(field); + customFieldFilters[key] = filter.value as string[]; + } + }); + + return customFieldFilters; +} + /** * Function that returns an array with all undefined values removed. */ diff --git a/client/src/utils/queryFields.ts b/client/src/utils/queryFields.ts index 7cde38c05..f7a048a74 100644 --- a/client/src/utils/queryFields.ts +++ b/client/src/utils/queryFields.ts @@ -110,6 +110,24 @@ export function useSetField(entity_type: EntityType) { }); } +/** + * Checks if a field is a custom field (starts with "extra.") + * @param field The field to check + * @returns True if the field is a custom field + */ +export function isCustomField(field: string): boolean { + return field.startsWith("extra."); +} + +/** + * Extracts the key from a custom field (removes the "extra." prefix) + * @param field The custom field + * @returns The key of the custom field + */ +export function getCustomFieldKey(field: string): string { + return field.substring(6); // Remove "extra." prefix +} + export function useDeleteField(entity_type: EntityType) { const queryClient = useQueryClient(); diff --git a/client/src/utils/sorting.ts b/client/src/utils/sorting.ts index 543d72546..c75603d2a 100644 --- a/client/src/utils/sorting.ts +++ b/client/src/utils/sorting.ts @@ -1,8 +1,9 @@ import { CrudSort } from "@refinedev/core"; import { SortOrder } from "antd/es/table/interface"; +import { getCustomFieldKey, isCustomField } from "./queryFields"; interface TypedCrudSort { - field: keyof Obj; + field: keyof Obj | string; order: "asc" | "desc"; } @@ -12,9 +13,9 @@ interface TypedCrudSort { * @param field The field to get the sort order for. * @returns The sort order for the given field, or undefined if the field is not being sorted. */ -export function getSortOrderForField( +export function getSortOrderForField( sorters: TypedCrudSort[], - field: Field, + field: Field | string, ): SortOrder | undefined { const sorter = sorters.find((s) => s.field === field); if (sorter) { @@ -26,3 +27,33 @@ export function getSortOrderForField( export function typeSorters(sorters: CrudSort[]): TypedCrudSort[] { return sorters as TypedCrudSort[]; // <-- Unsafe cast } + +/** + * Checks if a sorter is for a custom field + * @param sorter The sorter to check + * @returns True if the sorter is for a custom field + */ +export function isCustomFieldSorter(sorter: TypedCrudSort | CrudSort): boolean { + return typeof sorter.field === 'string' && isCustomField(sorter.field); +} + +/** + * Extracts all custom field sorters from a list of sorters + * @param sorters The list of sorters + * @returns An object with custom field keys and their sort orders + */ +export function getCustomFieldSorters( + sorters: TypedCrudSort[] | CrudSort[] +): Record { + const customFieldSorters: Record = {}; + + sorters.forEach((sorter) => { + if (isCustomFieldSorter(sorter)) { + const field = sorter.field.toString(); + const key = getCustomFieldKey(field); + customFieldSorters[key] = sorter.order; + } + }); + + return customFieldSorters; +} diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index 3e3f859af..e10963fb7 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -4,7 +4,7 @@ import logging from typing import Annotated -from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, Depends, Query, Request, WebSocket, WebSocketDisconnect from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse from pydantic import BaseModel, Field, field_validator, model_validator @@ -201,6 +201,7 @@ def prevent_none(cls: type["FilamentUpdateParameters"], v: float | None) -> floa ) async def find( *, + request: Request, db: Annotated[AsyncSession, Depends(get_db_session)], vendor_name_old: Annotated[ str | None, @@ -342,6 +343,14 @@ async def find( else: filter_by_ids = None + # Extract custom field filters from query parameters + extra_field_filters = {} + query_params = request.query_params + for key, value in query_params.items(): + if key.startswith("extra."): + field_key = key[6:] # Remove "extra." prefix + extra_field_filters[field_key] = value + db_items, total_count = await filament.find( db=db, ids=filter_by_ids, @@ -351,6 +360,7 @@ async def find( material=material, article_number=article_number, external_id=external_id, + extra_field_filters=extra_field_filters if extra_field_filters else None, sort_by=sort_by, limit=limit, offset=offset, diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 8f667e3da..ef659a327 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -5,7 +5,7 @@ from datetime import datetime from typing import Annotated -from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, Depends, Query, Request, WebSocket, WebSocketDisconnect from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse from pydantic import BaseModel, Field, field_validator @@ -127,6 +127,7 @@ class SpoolMeasureParameters(BaseModel): ) async def find( *, + request: Request, db: Annotated[AsyncSession, Depends(get_db_session)], filament_name_old: Annotated[ str | None, @@ -285,6 +286,14 @@ async def find( else: filament_vendor_ids = None + # Extract custom field filters from query parameters + extra_field_filters = {} + query_params = request.query_params + for key, value in query_params.items(): + if key.startswith("extra."): + field_key = key[6:] # Remove "extra." prefix + extra_field_filters[field_key] = value + db_items, total_count = await spool.find( db=db, filament_name=filament_name if filament_name is not None else filament_name_old, @@ -295,6 +304,7 @@ async def find( location=location, lot_nr=lot_nr, allow_archived=allow_archived, + extra_field_filters=extra_field_filters if extra_field_filters else None, sort_by=sort_by, limit=limit, offset=offset, diff --git a/spoolman/api/v1/vendor.py b/spoolman/api/v1/vendor.py index 9216fba30..54601228a 100644 --- a/spoolman/api/v1/vendor.py +++ b/spoolman/api/v1/vendor.py @@ -3,7 +3,7 @@ import asyncio from typing import Annotated -from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, Depends, Query, Request, WebSocket, WebSocketDisconnect from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse from pydantic import BaseModel, Field, field_validator @@ -79,6 +79,7 @@ def prevent_none(cls: type["VendorUpdateParameters"], v: str | None) -> str | No }, ) async def find( + request: Request, db: Annotated[AsyncSession, Depends(get_db_session)], name: Annotated[ str | None, @@ -124,10 +125,19 @@ async def find( field, direction = sort_item.split(":") sort_by[field] = SortOrder[direction.upper()] + # Extract custom field filters from query parameters + extra_field_filters = {} + query_params = request.query_params + for key, value in query_params.items(): + if key.startswith("extra."): + field_key = key[6:] # Remove "extra." prefix + extra_field_filters[field_key] = value + db_items, total_count = await vendor.find( db=db, name=name, external_id=external_id, + extra_field_filters=extra_field_filters if extra_field_filters else None, sort_by=sort_by, limit=limit, offset=offset, diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index e2d742758..8818cec70 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -12,14 +12,7 @@ from spoolman.api.v1.models import EventType, Filament, FilamentEvent, MultiColorDirection from spoolman.database import models, vendor -from spoolman.database.utils import ( - SortOrder, - add_where_clause_int_in, - add_where_clause_int_opt, - add_where_clause_str, - add_where_clause_str_opt, - parse_nested_field, -) +from spoolman.database.utils import SortOrder from spoolman.exceptions import ItemDeleteError, ItemNotFoundError from spoolman.math import delta_e, hex_to_rgb, rgb_to_lab from spoolman.ws import websocket_manager @@ -102,6 +95,7 @@ async def find( material: str | None = None, article_number: str | None = None, external_id: str | None = None, + extra_field_filters: dict[str, str] | None = None, sort_by: dict[str, SortOrder] | None = None, limit: int | None = None, offset: int = 0, @@ -113,6 +107,17 @@ async def find( Returns a tuple containing the list of items and the total count of matching items. """ + # Import here to avoid circular imports + from spoolman.database.utils import ( + add_where_clause_int_in, + add_where_clause_int_opt, + add_where_clause_str, + add_where_clause_str_opt, + add_where_clause_extra_field, + add_order_by_extra_field, + parse_nested_field, + ) + stmt = ( select(models.Filament) .options(contains_eager(models.Filament.vendor)) @@ -135,13 +140,54 @@ async def find( stmt = stmt.offset(offset).limit(limit) + # Apply extra field filters if provided + if extra_field_filters: + # Get all extra fields for filaments + from spoolman.extra_fields import EntityType, get_extra_fields + + extra_fields = await get_extra_fields(db, EntityType.filament) + extra_fields_dict = {field.key: field for field in extra_fields} + + for field_key, value in extra_field_filters.items(): + if field_key in extra_fields_dict: + field = extra_fields_dict[field_key] + stmt = add_where_clause_extra_field( + stmt, + models.Filament, + EntityType.filament, + field_key, + field.field_type, + value, + field.multi_choice if field.field_type == "choice" else None + ) + 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()) + # Check if this is a custom field sort + if fieldstr.startswith("extra."): + field_key = fieldstr[6:] # Remove "extra." prefix + + # Get the field definition + from spoolman.extra_fields import EntityType, get_extra_fields + + extra_fields = await get_extra_fields(db, EntityType.filament) + extra_field = next((f for f in extra_fields if f.key == field_key), None) + + if extra_field: + stmt = add_order_by_extra_field( + stmt, + models.Filament, + EntityType.filament, + field_key, + extra_field.field_type, + order + ) + else: + 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, diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 5c190ce65..a4541b72e 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -13,14 +13,7 @@ from spoolman.api.v1.models import EventType, Spool, SpoolEvent 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.database.utils import SortOrder from spoolman.exceptions import ItemCreateError, ItemNotFoundError, SpoolMeasureError from spoolman.math import weight_from_length from spoolman.ws import websocket_manager @@ -122,6 +115,7 @@ async def find( # noqa: C901, PLR0912 location: str | None = None, lot_nr: str | None = None, allow_archived: bool = False, + extra_field_filters: dict[str, str] | None = None, sort_by: dict[str, SortOrder] | None = None, limit: int | None = None, offset: int = 0, @@ -133,6 +127,17 @@ async def find( # noqa: C901, PLR0912 Returns a tuple containing the list of items and the total count of matching items. """ + # Import here to avoid circular imports + from spoolman.database.utils import ( + add_where_clause_int, + add_where_clause_int_opt, + add_where_clause_str, + add_where_clause_str_opt, + add_where_clause_extra_field, + add_order_by_extra_field, + parse_nested_field, + ) + stmt = ( sqlalchemy.select(models.Spool) .join(models.Spool.filament, isouter=True) @@ -165,37 +170,78 @@ async def find( # noqa: C901, PLR0912 stmt = stmt.offset(offset).limit(limit) + # Apply extra field filters if provided + if extra_field_filters: + # Get all extra fields for spools + from spoolman.extra_fields import EntityType, get_extra_fields + + extra_fields = await get_extra_fields(db, EntityType.spool) + extra_fields_dict = {field.key: field for field in extra_fields} + + for field_key, value in extra_field_filters.items(): + if field_key in extra_fields_dict: + field = extra_fields_dict[field_key] + stmt = add_where_clause_extra_field( + stmt, + models.Spool, + EntityType.spool, + field_key, + field.field_type, + value, + field.multi_choice if field.field_type == "choice" else None + ) + if sort_by is not None: for fieldstr, order in sort_by.items(): - sorts = [] - if fieldstr == "remaining_weight": - sorts.append(coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight) - elif fieldstr == "remaining_length": - # Simplified weight -> length formula. Absolute value is not correct but the proportionality is still - # kept, which means the sort order is correct. - sorts.append( - (coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight) - / models.Filament.density - / (models.Filament.diameter * models.Filament.diameter), - ) - elif fieldstr == "used_length": - sorts.append( - models.Spool.used_weight - / models.Filament.density - / (models.Filament.diameter * models.Filament.diameter), - ) - elif fieldstr == "filament.combined_name": - sorts.append(models.Vendor.name) - sorts.append(models.Filament.name) - elif fieldstr == "price": - sorts.append(coalesce(models.Spool.price, models.Filament.price)) + # Check if this is a custom field sort + if fieldstr.startswith("extra."): + field_key = fieldstr[6:] # Remove "extra." prefix + + # Get the field definition + from spoolman.extra_fields import EntityType, get_extra_fields + + extra_fields = await get_extra_fields(db, EntityType.spool) + extra_field = next((f for f in extra_fields if f.key == field_key), None) + + if extra_field: + stmt = add_order_by_extra_field( + stmt, + models.Spool, + EntityType.spool, + field_key, + extra_field.field_type, + order + ) else: - sorts.append(parse_nested_field(models.Spool, fieldstr)) - - if order == SortOrder.ASC: - stmt = stmt.order_by(*(f.asc() for f in sorts)) - elif order == SortOrder.DESC: - stmt = stmt.order_by(*(f.desc() for f in sorts)) + sorts = [] + if fieldstr == "remaining_weight": + sorts.append(coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight) + elif fieldstr == "remaining_length": + # Simplified weight -> length formula. Absolute value is not correct but the proportionality is still + # kept, which means the sort order is correct. + sorts.append( + (coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight) + / models.Filament.density + / (models.Filament.diameter * models.Filament.diameter), + ) + elif fieldstr == "used_length": + sorts.append( + models.Spool.used_weight + / models.Filament.density + / (models.Filament.diameter * models.Filament.diameter), + ) + elif fieldstr == "filament.combined_name": + sorts.append(models.Vendor.name) + sorts.append(models.Filament.name) + elif fieldstr == "price": + sorts.append(coalesce(models.Spool.price, models.Filament.price)) + else: + sorts.append(parse_nested_field(models.Spool, fieldstr)) + + if order == SortOrder.ASC: + stmt = stmt.order_by(*(f.asc() for f in sorts)) + elif order == SortOrder.DESC: + stmt = stmt.order_by(*(f.desc() for f in sorts)) rows = await db.execute( stmt, diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py index 2d8776c00..888d9deaf 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -1,14 +1,20 @@ """Utility functions for the database module.""" from collections.abc import Sequence +import json from enum import Enum -from typing import Any, TypeVar +from typing import Any, Dict, Tuple, Type, TypeVar import sqlalchemy -from sqlalchemy import Select -from sqlalchemy.orm import attributes +from sqlalchemy import Select, and_, cast, func, or_, text +from sqlalchemy.orm import attributes, aliased +from sqlalchemy.sql import expression from spoolman.database import models +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from spoolman.extra_fields import EntityType, ExtraField, ExtraFieldType class SortOrder(Enum): @@ -129,3 +135,213 @@ def add_where_clause_int_in( if value is not None: stmt = stmt.where(field.in_(value)) return stmt + + +def get_field_table_for_entity(entity_type: Any) -> Type[models.Base]: + """Get the field table class for a given entity type.""" + # Import here to avoid circular imports + from spoolman.extra_fields import EntityType + + if entity_type == EntityType.spool: + return models.SpoolField + elif entity_type == EntityType.filament: + return models.FilamentField + elif entity_type == EntityType.vendor: + return models.VendorField + else: + raise ValueError(f"Unknown entity type: {entity_type}") + + +def get_entity_id_column(field_table: Type[models.Base]) -> attributes.InstrumentedAttribute[int]: + """Get the entity ID column for a given field table.""" + if field_table == models.SpoolField: + return models.SpoolField.spool_id + elif field_table == models.FilamentField: + return models.FilamentField.filament_id + elif field_table == models.VendorField: + return models.VendorField.vendor_id + else: + raise ValueError(f"Unknown field table: {field_table}") + + +def add_where_clause_extra_field( + stmt: Select, + base_obj: Type[models.Base], + entity_type: Any, + field_key: str, + field_type: Any, + value: str, + multi_choice: bool | None = None, +) -> Select: + """Add a where clause to a select statement for an extra field. + Args: + stmt: The select statement to add the where clause to + base_obj: The base object type (Spool, Filament, Vendor) + entity_type: The entity type + field_key: The key of the extra field + field_type: The type of the extra field + value: The value to filter by + multi_choice: Whether the field is a multi-choice field (only for choice fields) + Returns: + The modified select statement + """ + # Import here to avoid circular imports + from spoolman.extra_fields import ExtraFieldType + + field_table = get_field_table_for_entity(entity_type) + entity_id_column = get_entity_id_column(field_table) + + value_parts = value.split(",") + + # Handle filtering for empty values + if any(p == "" or len(p) == 0 for p in value_parts): + # An item is considered "empty" if: + # A) A row exists in the field table, and its value is null, 'null', or 'false' for booleans. + # B) No row exists in the field table for this item and field_key. + + # Condition A subquery + empty_conditions = [ + field_table.value.is_(None), + field_table.value == "null", + ] + if field_type == ExtraFieldType.boolean: + empty_conditions.append(field_table.value == json.dumps(False)) + + subq_a = sqlalchemy.select(entity_id_column).where( + sqlalchemy.and_(field_table.key == field_key, sqlalchemy.or_(*empty_conditions)) + ) + + # Condition B subquery + subq_b = sqlalchemy.select(base_obj.id).where( + getattr(base_obj, "id").not_in(sqlalchemy.select(entity_id_column).where(field_table.key == field_key)) + ) + + return stmt.where( + sqlalchemy.or_( + getattr(base_obj, "id").in_(subq_a), + getattr(base_obj, "id").in_(subq_b), + ) + ) + + # Handle filtering for specific values + conditions = [] + for value_part in value_parts: + exact_match = value_part.startswith('"') and value_part.endswith('"') + if exact_match: + value_part = value_part[1:-1] + + if field_type == ExtraFieldType.text: + if exact_match: + conditions.append(field_table.value == json.dumps(value_part)) + else: + conditions.append(field_table.value.ilike(f"%{value_part}%")) + elif field_type == ExtraFieldType.integer: + try: + conditions.append(field_table.value == json.dumps(int(value_part))) + except ValueError: + pass + elif field_type == ExtraFieldType.float: + try: + conditions.append(field_table.value == json.dumps(float(value_part))) + except ValueError: + pass + elif field_type == ExtraFieldType.boolean: + bool_value = value_part.lower() in ("true", "1", "yes") + conditions.append(field_table.value == json.dumps(bool_value)) + elif field_type == ExtraFieldType.choice: + if multi_choice: + conditions.append(field_table.value.like(f'%"{value_part}"%')) + else: + conditions.append(field_table.value == json.dumps(value_part)) + elif field_type == ExtraFieldType.datetime: + conditions.append(field_table.value == json.dumps(value_part)) + elif field_type in (ExtraFieldType.integer_range, ExtraFieldType.float_range): + if ":" in value_part: + min_val_str, max_val_str = value_part.split(":", 1) + converter = int if field_type == ExtraFieldType.integer_range else float + try: + if min_val_str: + conditions.append(func.json_extract(field_table.value, "$[0]") >= converter(min_val_str)) + if max_val_str: + conditions.append(func.json_extract(field_table.value, "$[1]") <= converter(max_val_str)) + except (ValueError, TypeError): + pass + + if not conditions: + return stmt + + subq = sqlalchemy.select(entity_id_column).where( + sqlalchemy.and_(field_table.key == field_key, sqlalchemy.or_(*conditions)) + ) + + return stmt.where(getattr(base_obj, "id").in_(subq)) + + +def add_order_by_extra_field( + stmt: Select, + base_obj: Type[models.Base], + entity_type: Any, + field_key: str, + field_type: Any, + order: SortOrder, +) -> Select: + """Add an order by clause to a select statement for an extra field. + + Args: + stmt: The select statement to add the order by clause to + base_obj: The base object type (Spool, Filament, Vendor) + entity_type: The entity type + field_key: The key of the extra field + field_type: The type of the extra field + order: The sort order + + Returns: + The modified select statement + """ + # Import here to avoid circular imports + from spoolman.extra_fields import EntityType, ExtraFieldType + + # Use a subquery approach instead of joins + field_table = get_field_table_for_entity(entity_type) + entity_id_column = get_entity_id_column(field_table) + + # Create a subquery that selects the value for each entity + value_subq = ( + sqlalchemy.select(field_table.value) + .where( + sqlalchemy.and_( + field_table.key == field_key, + entity_id_column == getattr(base_obj, "id") + ) + ) + .scalar_subquery() + .correlate(base_obj) + ) + + # Create a sort expression based on the field type + if field_type == ExtraFieldType.integer: + # Cast the JSON value to an integer for sorting + sort_expr = func.cast(func.json_extract(value_subq, '$'), sqlalchemy.Integer) + elif field_type == ExtraFieldType.float: + # Cast the JSON value to a float for sorting + sort_expr = func.cast(func.json_extract(value_subq, '$'), sqlalchemy.Float) + elif field_type == ExtraFieldType.datetime: + # For datetime fields, we can sort by the ISO string + sort_expr = value_subq + elif field_type == ExtraFieldType.boolean: + # For boolean fields, true comes after false + sort_expr = value_subq + elif field_type in (ExtraFieldType.integer_range, ExtraFieldType.float_range): + # For range fields, sort by the first value in the range + sort_expr = func.json_extract(value_subq, '$[0]') + else: + # For text and choice fields, sort by the string value + sort_expr = value_subq + + # Apply the sort order + if order == SortOrder.ASC: + stmt = stmt.order_by(sort_expr.asc()) + else: + stmt = stmt.order_by(sort_expr.desc()) + + return stmt diff --git a/spoolman/database/vendor.py b/spoolman/database/vendor.py index f2e83018e..51ed2e509 100644 --- a/spoolman/database/vendor.py +++ b/spoolman/database/vendor.py @@ -9,7 +9,7 @@ from spoolman.api.v1.models import EventType, Vendor, VendorEvent from spoolman.database import models -from spoolman.database.utils import SortOrder, add_where_clause_str, add_where_clause_str_opt +from spoolman.database.utils import SortOrder from spoolman.exceptions import ItemNotFoundError from spoolman.ws import websocket_manager @@ -53,6 +53,7 @@ async def find( db: AsyncSession, name: str | None = None, external_id: str | None = None, + extra_field_filters: dict[str, str] | None = None, sort_by: dict[str, SortOrder] | None = None, limit: int | None = None, offset: int = 0, @@ -61,6 +62,14 @@ async def find( Returns a tuple containing the list of items and the total count of matching items. """ + # Import here to avoid circular imports + from spoolman.database.utils import ( + add_where_clause_str, + add_where_clause_str_opt, + add_where_clause_extra_field, + add_order_by_extra_field + ) + stmt = select(models.Vendor) stmt = add_where_clause_str(stmt, models.Vendor.name, name) @@ -74,13 +83,54 @@ async def find( stmt = stmt.offset(offset).limit(limit) + # Apply extra field filters if provided + if extra_field_filters: + # Get all extra fields for vendors + from spoolman.extra_fields import EntityType, get_extra_fields + + extra_fields = await get_extra_fields(db, EntityType.vendor) + extra_fields_dict = {field.key: field for field in extra_fields} + + for field_key, value in extra_field_filters.items(): + if field_key in extra_fields_dict: + field = extra_fields_dict[field_key] + stmt = add_where_clause_extra_field( + stmt, + models.Vendor, + EntityType.vendor, + field_key, + field.field_type, + value, + field.multi_choice if field.field_type == "choice" else None + ) + 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()) + # Check if this is a custom field sort + if fieldstr.startswith("extra."): + field_key = fieldstr[6:] # Remove "extra." prefix + + # Get the field definition + from spoolman.extra_fields import EntityType, get_extra_fields + + extra_fields = await get_extra_fields(db, EntityType.vendor) + extra_field = next((f for f in extra_fields if f.key == field_key), None) + + if extra_field: + stmt = add_order_by_extra_field( + stmt, + models.Vendor, + EntityType.vendor, + field_key, + extra_field.field_type, + order + ) + else: + 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, diff --git a/tests_integration/tests/fields/test_filter_sort.py b/tests_integration/tests/fields/test_filter_sort.py new file mode 100644 index 000000000..0b8077c55 --- /dev/null +++ b/tests_integration/tests/fields/test_filter_sort.py @@ -0,0 +1,176 @@ +"""Tests for filtering and sorting by custom fields.""" + +import json +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_filter_by_custom_field(client: AsyncClient, setup_extra_fields): + """Test filtering by custom field.""" + # Create a spool with a custom field + spool_data = { + "filament_id": 1, + "extra": { + "test_field": json.dumps("test_value") + } + } + response = await client.post("/api/v1/spool", json=spool_data) + assert response.status_code == 200 + spool_id = response.json()["id"] + + # Create another spool with a different custom field value + spool_data2 = { + "filament_id": 1, + "extra": { + "test_field": json.dumps("other_value") + } + } + response = await client.post("/api/v1/spool", json=spool_data2) + assert response.status_code == 200 + spool_id2 = response.json()["id"] + + # Filter by custom field + response = await client.get("/api/v1/spool", params={"extra.test_field": "test_value"}) + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["id"] == spool_id + + # Filter by custom field with exact match + response = await client.get("/api/v1/spool", params={"extra.test_field": '"test_value"'}) + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["id"] == spool_id + + # Filter by custom field with multiple values + response = await client.get("/api/v1/spool", params={"extra.test_field": "test_value,other_value"}) + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert {item["id"] for item in data} == {spool_id, spool_id2} + + +@pytest.mark.asyncio +async def test_sort_by_custom_field(client: AsyncClient, setup_extra_fields): + """Test sorting by custom field.""" + # Create spools with custom fields of different types + # Text field + spool_data1 = { + "filament_id": 1, + "extra": { + "text_field": json.dumps("B value") + } + } + response = await client.post("/api/v1/spool", json=spool_data1) + assert response.status_code == 200 + spool_id1 = response.json()["id"] + + spool_data2 = { + "filament_id": 1, + "extra": { + "text_field": json.dumps("A value") + } + } + response = await client.post("/api/v1/spool", json=spool_data2) + assert response.status_code == 200 + spool_id2 = response.json()["id"] + + # Sort by custom field ascending + response = await client.get("/api/v1/spool", params={"sort": "extra.text_field:asc"}) + assert response.status_code == 200 + data = response.json() + assert len(data) >= 2 + # Find our test spools in the results + test_spools = [item for item in data if item["id"] in (spool_id1, spool_id2)] + assert len(test_spools) == 2 + assert test_spools[0]["id"] == spool_id2 # A value should come first + assert test_spools[1]["id"] == spool_id1 # B value should come second + + # Sort by custom field descending + response = await client.get("/api/v1/spool", params={"sort": "extra.text_field:desc"}) + assert response.status_code == 200 + data = response.json() + assert len(data) >= 2 + # Find our test spools in the results + test_spools = [item for item in data if item["id"] in (spool_id1, spool_id2)] + assert len(test_spools) == 2 + assert test_spools[0]["id"] == spool_id1 # B value should come first + assert test_spools[1]["id"] == spool_id2 # A value should come second + + +@pytest.mark.asyncio +async def test_filter_by_numeric_custom_field(client: AsyncClient, setup_extra_fields): + """Test filtering by numeric custom field.""" + # Create a spool with a numeric custom field + spool_data = { + "filament_id": 1, + "extra": { + "numeric_field": json.dumps(100) + } + } + response = await client.post("/api/v1/spool", json=spool_data) + assert response.status_code == 200 + spool_id = response.json()["id"] + + # Create another spool with a different numeric value + spool_data2 = { + "filament_id": 1, + "extra": { + "numeric_field": json.dumps(200) + } + } + response = await client.post("/api/v1/spool", json=spool_data2) + assert response.status_code == 200 + spool_id2 = response.json()["id"] + + # Filter by numeric custom field + response = await client.get("/api/v1/spool", params={"extra.numeric_field": "100"}) + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["id"] == spool_id + + # Sort by numeric custom field ascending + response = await client.get("/api/v1/spool", params={"sort": "extra.numeric_field:asc"}) + assert response.status_code == 200 + data = response.json() + # Find our test spools in the results + test_spools = [item for item in data if item["id"] in (spool_id, spool_id2)] + assert len(test_spools) == 2 + assert test_spools[0]["id"] == spool_id # 100 should come first + assert test_spools[1]["id"] == spool_id2 # 200 should come second + + +@pytest.mark.asyncio +async def test_filter_by_boolean_custom_field(client: AsyncClient, setup_extra_fields): + """Test filtering by boolean custom field.""" + # Create a spool with a boolean custom field + spool_data = { + "filament_id": 1, + "extra": { + "bool_field": json.dumps(True) + } + } + response = await client.post("/api/v1/spool", json=spool_data) + assert response.status_code == 200 + spool_id = response.json()["id"] + + # Create another spool with a different boolean value + spool_data2 = { + "filament_id": 1, + "extra": { + "bool_field": json.dumps(False) + } + } + response = await client.post("/api/v1/spool", json=spool_data2) + assert response.status_code == 200 + spool_id2 = response.json()["id"] + + # Filter by boolean custom field + response = await client.get("/api/v1/spool", params={"extra.bool_field": "true"}) + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["id"] == spool_id \ No newline at end of file From 33c669e9e3657d543e8ea24302b34377df756960 Mon Sep 17 00:00:00 2001 From: Dieter Blomme Date: Sun, 8 Feb 2026 23:34:39 +0100 Subject: [PATCH 02/16] Fix tests and make code postgres compatible --- spoolman/database/utils.py | 10 +- .../tests/fields/test_filter_sort.py | 302 +++++++++++------- 2 files changed, 198 insertions(+), 114 deletions(-) diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py index 888d9deaf..41744cb79 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -261,9 +261,9 @@ def add_where_clause_extra_field( converter = int if field_type == ExtraFieldType.integer_range else float try: if min_val_str: - conditions.append(func.json_extract(field_table.value, "$[0]") >= converter(min_val_str)) + conditions.append(field_table.value[0].as_integer() >= converter(min_val_str)) if max_val_str: - conditions.append(func.json_extract(field_table.value, "$[1]") <= converter(max_val_str)) + conditions.append(field_table.value[1].as_integer() <= converter(min_val_str)) except (ValueError, TypeError): pass @@ -321,10 +321,10 @@ def add_order_by_extra_field( # Create a sort expression based on the field type if field_type == ExtraFieldType.integer: # Cast the JSON value to an integer for sorting - sort_expr = func.cast(func.json_extract(value_subq, '$'), sqlalchemy.Integer) + sort_expr = func.cast(value_subq, sqlalchemy.Integer) elif field_type == ExtraFieldType.float: # Cast the JSON value to a float for sorting - sort_expr = func.cast(func.json_extract(value_subq, '$'), sqlalchemy.Float) + sort_expr = func.cast(value_subq, sqlalchemy.Float) elif field_type == ExtraFieldType.datetime: # For datetime fields, we can sort by the ISO string sort_expr = value_subq @@ -333,7 +333,7 @@ def add_order_by_extra_field( sort_expr = value_subq elif field_type in (ExtraFieldType.integer_range, ExtraFieldType.float_range): # For range fields, sort by the first value in the range - sort_expr = func.json_extract(value_subq, '$[0]') + sort_expr = value_subq[0] else: # For text and choice fields, sort by the string value sort_expr = value_subq diff --git a/tests_integration/tests/fields/test_filter_sort.py b/tests_integration/tests/fields/test_filter_sort.py index 0b8077c55..141260582 100644 --- a/tests_integration/tests/fields/test_filter_sort.py +++ b/tests_integration/tests/fields/test_filter_sort.py @@ -1,86 +1,124 @@ """Tests for filtering and sorting by custom fields.""" +import httpx import json import pytest -from httpx import AsyncClient +from typing import Any + +from ..conftest import URL, assert_httpx_success, assert_lists_compatible @pytest.mark.asyncio -async def test_filter_by_custom_field(client: AsyncClient, setup_extra_fields): +async def test_filter_by_custom_field(random_filament: dict[str, Any]): + """Add a custom text field""" + result = httpx.post( + f"{URL}/api/v1/field/spool/test_field", + json={ + "name": "Test field", + "field_type": "text", + "default_value": json.dumps("Hello World"), + }, + ) + assert_httpx_success(result) + """Test filtering by custom field.""" # Create a spool with a custom field - spool_data = { - "filament_id": 1, - "extra": { - "test_field": json.dumps("test_value") - } - } - response = await client.post("/api/v1/spool", json=spool_data) - assert response.status_code == 200 - spool_id = response.json()["id"] + result = httpx.post( + f"{URL}/api/v1/spool", + json={ + "filament_id": random_filament["id"], + "extra": { + "test_field": json.dumps("test_value") + } + }, + ) + assert_httpx_success(result) + spool_id1 = result.json()["id"] # Create another spool with a different custom field value - spool_data2 = { - "filament_id": 1, - "extra": { - "test_field": json.dumps("other_value") - } - } - response = await client.post("/api/v1/spool", json=spool_data2) - assert response.status_code == 200 - spool_id2 = response.json()["id"] + result = httpx.post( + f"{URL}/api/v1/spool", + json={ + "filament_id": random_filament["id"], + "extra": { + "test_field": json.dumps("other_value") + } + }, + ) + assert_httpx_success(result) + spool_id2 = result.json()["id"] # Filter by custom field - response = await client.get("/api/v1/spool", params={"extra.test_field": "test_value"}) - assert response.status_code == 200 - data = response.json() + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.test_field": "test_value"}) + assert_httpx_success(result) + data = result.json() assert len(data) == 1 - assert data[0]["id"] == spool_id + assert data[0]["id"] == spool_id1 # Filter by custom field with exact match - response = await client.get("/api/v1/spool", params={"extra.test_field": '"test_value"'}) - assert response.status_code == 200 - data = response.json() + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.test_field": '"test_value"'}) + assert_httpx_success(result) + data = result.json() assert len(data) == 1 - assert data[0]["id"] == spool_id + assert data[0]["id"] == spool_id1 # Filter by custom field with multiple values - response = await client.get("/api/v1/spool", params={"extra.test_field": "test_value,other_value"}) - assert response.status_code == 200 - data = response.json() + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.test_field": "test_value,other_value"}) + assert_httpx_success(result) + data = result.json() assert len(data) == 2 - assert {item["id"] for item in data} == {spool_id, spool_id2} + assert {item["id"] for item in data} == {spool_id1, spool_id2} + + # Clean up + result = httpx.delete(f"{URL}/api/v1/field/spool/test_field") + assert_httpx_success(result) + httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() @pytest.mark.asyncio -async def test_sort_by_custom_field(client: AsyncClient, setup_extra_fields): +async def test_sort_by_custom_field(random_filament: dict[str, Any]): + """Add a custom text field""" + result = httpx.post( + f"{URL}/api/v1/field/spool/text_field", + json={ + "name": "Text field", + "field_type": "text", + }, + ) + assert_httpx_success(result) + """Test sorting by custom field.""" # Create spools with custom fields of different types # Text field - spool_data1 = { - "filament_id": 1, - "extra": { - "text_field": json.dumps("B value") - } - } - response = await client.post("/api/v1/spool", json=spool_data1) - assert response.status_code == 200 - spool_id1 = response.json()["id"] - - spool_data2 = { - "filament_id": 1, - "extra": { - "text_field": json.dumps("A value") - } - } - response = await client.post("/api/v1/spool", json=spool_data2) - assert response.status_code == 200 - spool_id2 = response.json()["id"] + result = httpx.post( + f"{URL}/api/v1/spool", + json={ + "filament_id": random_filament["id"], + "extra": { + "text_field": json.dumps("B value") + } + }, + ) + assert_httpx_success(result) + spool_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/spool", + json={ + "filament_id": random_filament["id"], + "extra": { + "text_field": json.dumps("A value") + } + }, + ) + assert_httpx_success(result) + spool_id2 = result.json()["id"] # Sort by custom field ascending - response = await client.get("/api/v1/spool", params={"sort": "extra.text_field:asc"}) - assert response.status_code == 200 - data = response.json() + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.text_field:asc"}) + assert_httpx_success(result) + data = result.json() assert len(data) >= 2 # Find our test spools in the results test_spools = [item for item in data if item["id"] in (spool_id1, spool_id2)] @@ -89,9 +127,9 @@ async def test_sort_by_custom_field(client: AsyncClient, setup_extra_fields): assert test_spools[1]["id"] == spool_id1 # B value should come second # Sort by custom field descending - response = await client.get("/api/v1/spool", params={"sort": "extra.text_field:desc"}) - assert response.status_code == 200 - data = response.json() + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.text_field:desc"}) + assert_httpx_success(result) + data = result.json() assert len(data) >= 2 # Find our test spools in the results test_spools = [item for item in data if item["id"] in (spool_id1, spool_id2)] @@ -99,78 +137,124 @@ async def test_sort_by_custom_field(client: AsyncClient, setup_extra_fields): assert test_spools[0]["id"] == spool_id1 # B value should come first assert test_spools[1]["id"] == spool_id2 # A value should come second + # Clean up + result = httpx.delete(f"{URL}/api/v1/field/spool/text_field") + assert_httpx_success(result) + httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() + @pytest.mark.asyncio -async def test_filter_by_numeric_custom_field(client: AsyncClient, setup_extra_fields): +async def test_filter_by_numeric_custom_field(random_filament: dict[str, Any]): + """Add a custom numeric field""" + result = httpx.post( + f"{URL}/api/v1/field/spool/numeric_field", + json={ + "name": "Numeric field", + "field_type": "integer", + }, + ) + assert_httpx_success(result) + """Test filtering by numeric custom field.""" # Create a spool with a numeric custom field - spool_data = { - "filament_id": 1, - "extra": { - "numeric_field": json.dumps(100) - } - } - response = await client.post("/api/v1/spool", json=spool_data) - assert response.status_code == 200 - spool_id = response.json()["id"] + result = httpx.post( + f"{URL}/api/v1/spool", + json={ + "filament_id": random_filament["id"], + "extra": { + "numeric_field": json.dumps(100) + } + }, + ) + assert_httpx_success(result) + spool_id1 = result.json()["id"] # Create another spool with a different numeric value - spool_data2 = { - "filament_id": 1, - "extra": { - "numeric_field": json.dumps(200) - } - } - response = await client.post("/api/v1/spool", json=spool_data2) - assert response.status_code == 200 - spool_id2 = response.json()["id"] + result = httpx.post( + f"{URL}/api/v1/spool", + json={ + "filament_id": random_filament["id"], + "extra": { + "numeric_field": json.dumps(200) + } + }, + ) + assert_httpx_success(result) + spool_id2 = result.json()["id"] # Filter by numeric custom field - response = await client.get("/api/v1/spool", params={"extra.numeric_field": "100"}) - assert response.status_code == 200 - data = response.json() + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.numeric_field": "100"}) + assert_httpx_success(result) + data = result.json() assert len(data) == 1 - assert data[0]["id"] == spool_id + assert data[0]["id"] == spool_id1 # Sort by numeric custom field ascending - response = await client.get("/api/v1/spool", params={"sort": "extra.numeric_field:asc"}) - assert response.status_code == 200 - data = response.json() + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.numeric_field:asc"}) + assert_httpx_success(result) + data = result.json() # Find our test spools in the results - test_spools = [item for item in data if item["id"] in (spool_id, spool_id2)] + test_spools = [item for item in data if item["id"] in (spool_id1, spool_id2)] assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id # 100 should come first + assert test_spools[0]["id"] == spool_id1 # 100 should come first assert test_spools[1]["id"] == spool_id2 # 200 should come second + # Clean up + result = httpx.delete(f"{URL}/api/v1/field/spool/numeric_field") + assert_httpx_success(result) + httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() + @pytest.mark.asyncio -async def test_filter_by_boolean_custom_field(client: AsyncClient, setup_extra_fields): +async def test_filter_by_boolean_custom_field(random_filament: dict[str, Any]): + """Add a custom boolean field""" + result = httpx.post( + f"{URL}/api/v1/field/spool/boolean_field", + json={ + "name": "Boolean field", + "field_type": "boolean", + }, + ) + assert_httpx_success(result) + """Test filtering by boolean custom field.""" # Create a spool with a boolean custom field - spool_data = { - "filament_id": 1, - "extra": { - "bool_field": json.dumps(True) - } - } - response = await client.post("/api/v1/spool", json=spool_data) - assert response.status_code == 200 - spool_id = response.json()["id"] + result = httpx.post( + f"{URL}/api/v1/spool", + json={ + "filament_id": random_filament["id"], + "extra": { + "boolean_field": json.dumps(True) + } + }, + ) + assert_httpx_success(result) + spool_id1 = result.json()["id"] # Create another spool with a different boolean value - spool_data2 = { - "filament_id": 1, - "extra": { - "bool_field": json.dumps(False) - } - } - response = await client.post("/api/v1/spool", json=spool_data2) - assert response.status_code == 200 - spool_id2 = response.json()["id"] + result = httpx.post( + f"{URL}/api/v1/spool", + json={ + "filament_id": random_filament["id"], + "extra": { + "boolean_field": json.dumps(False) + } + }, + ) + assert_httpx_success(result) + spool_id2 = result.json()["id"] # Filter by boolean custom field - response = await client.get("/api/v1/spool", params={"extra.bool_field": "true"}) - assert response.status_code == 200 - data = response.json() + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.boolean_field": "true"}) + assert_httpx_success(result) + data = result.json() assert len(data) == 1 - assert data[0]["id"] == spool_id \ No newline at end of file + assert data[0]["id"] == spool_id1 + + # Clean up + result = httpx.delete(f"{URL}/api/v1/field/spool/boolean_field") + assert_httpx_success(result) + httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() \ No newline at end of file From 4f24594569fad45160729ca97f33c0157843cb12 Mon Sep 17 00:00:00 2001 From: Dieter Blomme Date: Sun, 22 Feb 2026 22:35:24 +0100 Subject: [PATCH 03/16] Fix bugs and expand test coverage for custom field filter/sort --- spoolman/database/filament.py | 8 +- spoolman/database/spool.py | 8 +- spoolman/database/utils.py | 17 +- spoolman/database/vendor.py | 8 +- .../tests/fields/test_filter_sort.py | 310 +++++++++++++++++- 5 files changed, 327 insertions(+), 24 deletions(-) diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index 8818cec70..130981ae8 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -143,11 +143,11 @@ async def find( # Apply extra field filters if provided if extra_field_filters: # Get all extra fields for filaments - from spoolman.extra_fields import EntityType, get_extra_fields - + from spoolman.extra_fields import EntityType, ExtraFieldType, get_extra_fields + extra_fields = await get_extra_fields(db, EntityType.filament) extra_fields_dict = {field.key: field for field in extra_fields} - + for field_key, value in extra_field_filters.items(): if field_key in extra_fields_dict: field = extra_fields_dict[field_key] @@ -158,7 +158,7 @@ async def find( field_key, field.field_type, value, - field.multi_choice if field.field_type == "choice" else None + field.multi_choice if field.field_type == ExtraFieldType.choice else None, ) if sort_by is not None: diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index a4541b72e..89c58b291 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -173,11 +173,11 @@ async def find( # noqa: C901, PLR0912 # Apply extra field filters if provided if extra_field_filters: # Get all extra fields for spools - from spoolman.extra_fields import EntityType, get_extra_fields - + from spoolman.extra_fields import EntityType, ExtraFieldType, get_extra_fields + extra_fields = await get_extra_fields(db, EntityType.spool) extra_fields_dict = {field.key: field for field in extra_fields} - + for field_key, value in extra_field_filters.items(): if field_key in extra_fields_dict: field = extra_fields_dict[field_key] @@ -188,7 +188,7 @@ async def find( # noqa: C901, PLR0912 field_key, field.field_type, value, - field.multi_choice if field.field_type == "choice" else None + field.multi_choice if field.field_type == ExtraFieldType.choice else None, ) if sort_by is not None: diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py index 41744cb79..54b96c739 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -3,18 +3,13 @@ from collections.abc import Sequence import json from enum import Enum -from typing import Any, Dict, Tuple, Type, TypeVar +from typing import Any, Type, TypeVar import sqlalchemy -from sqlalchemy import Select, and_, cast, func, or_, text -from sqlalchemy.orm import attributes, aliased -from sqlalchemy.sql import expression +from sqlalchemy import Select +from sqlalchemy.orm import attributes from spoolman.database import models -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from spoolman.extra_fields import EntityType, ExtraField, ExtraFieldType class SortOrder(Enum): @@ -263,7 +258,7 @@ def add_where_clause_extra_field( if min_val_str: conditions.append(field_table.value[0].as_integer() >= converter(min_val_str)) if max_val_str: - conditions.append(field_table.value[1].as_integer() <= converter(min_val_str)) + conditions.append(field_table.value[1].as_integer() <= converter(max_val_str)) except (ValueError, TypeError): pass @@ -321,10 +316,10 @@ def add_order_by_extra_field( # Create a sort expression based on the field type if field_type == ExtraFieldType.integer: # Cast the JSON value to an integer for sorting - sort_expr = func.cast(value_subq, sqlalchemy.Integer) + sort_expr = sqlalchemy.cast(value_subq, sqlalchemy.Integer) elif field_type == ExtraFieldType.float: # Cast the JSON value to a float for sorting - sort_expr = func.cast(value_subq, sqlalchemy.Float) + sort_expr = sqlalchemy.cast(value_subq, sqlalchemy.Float) elif field_type == ExtraFieldType.datetime: # For datetime fields, we can sort by the ISO string sort_expr = value_subq diff --git a/spoolman/database/vendor.py b/spoolman/database/vendor.py index 51ed2e509..aa84a7c3d 100644 --- a/spoolman/database/vendor.py +++ b/spoolman/database/vendor.py @@ -86,11 +86,11 @@ async def find( # Apply extra field filters if provided if extra_field_filters: # Get all extra fields for vendors - from spoolman.extra_fields import EntityType, get_extra_fields - + from spoolman.extra_fields import EntityType, ExtraFieldType, get_extra_fields + extra_fields = await get_extra_fields(db, EntityType.vendor) extra_fields_dict = {field.key: field for field in extra_fields} - + for field_key, value in extra_field_filters.items(): if field_key in extra_fields_dict: field = extra_fields_dict[field_key] @@ -101,7 +101,7 @@ async def find( field_key, field.field_type, value, - field.multi_choice if field.field_type == "choice" else None + field.multi_choice if field.field_type == ExtraFieldType.choice else None, ) if sort_by is not None: diff --git a/tests_integration/tests/fields/test_filter_sort.py b/tests_integration/tests/fields/test_filter_sort.py index 141260582..80d6cc605 100644 --- a/tests_integration/tests/fields/test_filter_sort.py +++ b/tests_integration/tests/fields/test_filter_sort.py @@ -257,4 +257,312 @@ async def test_filter_by_boolean_custom_field(random_filament: dict[str, Any]): result = httpx.delete(f"{URL}/api/v1/field/spool/boolean_field") assert_httpx_success(result) httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() \ No newline at end of file + httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() + + +@pytest.mark.asyncio +async def test_filter_and_sort_float_custom_field(random_filament: dict[str, Any]): + """Test filtering and sorting by a float custom field.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/float_field", + json={"name": "Float field", "field_type": "float"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"], "extra": {"float_field": json.dumps(1.5)}}, + ) + assert_httpx_success(result) + spool_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"], "extra": {"float_field": json.dumps(2.5)}}, + ) + assert_httpx_success(result) + spool_id2 = result.json()["id"] + + # Filter by exact float value + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_field": "1.5"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id1 in ids + assert spool_id2 not in ids + + # Sort ascending: 1.5 before 2.5 + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.float_field:asc"}) + assert_httpx_success(result) + test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] + assert len(test_spools) == 2 + assert test_spools[0]["id"] == spool_id1 + assert test_spools[1]["id"] == spool_id2 + + # Sort descending: 2.5 before 1.5 + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.float_field:desc"}) + assert_httpx_success(result) + test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] + assert len(test_spools) == 2 + assert test_spools[0]["id"] == spool_id2 + assert test_spools[1]["id"] == spool_id1 + + # Clean up + httpx.delete(f"{URL}/api/v1/field/spool/float_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() + + +@pytest.mark.asyncio +async def test_filter_single_choice_custom_field(random_filament: dict[str, Any]): + """Test filtering by a single-choice custom field.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/choice_field", + json={ + "name": "Choice field", + "field_type": "choice", + "choices": ["OptionA", "OptionB", "OptionC"], + "multi_choice": False, + }, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"], "extra": {"choice_field": json.dumps("OptionA")}}, + ) + assert_httpx_success(result) + spool_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"], "extra": {"choice_field": json.dumps("OptionB")}}, + ) + assert_httpx_success(result) + spool_id2 = result.json()["id"] + + # Filter by a single choice value + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.choice_field": "OptionA"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id1 in ids + assert spool_id2 not in ids + + # Filter by multiple choices (OR) — both should be returned + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.choice_field": "OptionA,OptionB"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id1 in ids + assert spool_id2 in ids + + # Clean up + httpx.delete(f"{URL}/api/v1/field/spool/choice_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() + + +@pytest.mark.asyncio +async def test_filter_multi_choice_custom_field(random_filament: dict[str, Any]): + """Test filtering by a multi-choice custom field.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/multi_choice_field", + json={ + "name": "Multi-choice field", + "field_type": "choice", + "choices": ["A", "B", "C"], + "multi_choice": True, + }, + ) + assert_httpx_success(result) + + # Spool 1 has choices A and B + result = httpx.post( + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"], "extra": {"multi_choice_field": json.dumps(["A", "B"])}}, + ) + assert_httpx_success(result) + spool_id1 = result.json()["id"] + + # Spool 2 has only choice C + result = httpx.post( + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"], "extra": {"multi_choice_field": json.dumps(["C"])}}, + ) + assert_httpx_success(result) + spool_id2 = result.json()["id"] + + # Filter by A — only spool 1 has A + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.multi_choice_field": "A"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id1 in ids + assert spool_id2 not in ids + + # Filter by C — only spool 2 has C + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.multi_choice_field": "C"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id2 in ids + assert spool_id1 not in ids + + # Filter by A,C (OR) — both should be returned + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.multi_choice_field": "A,C"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id1 in ids + assert spool_id2 in ids + + # Clean up + httpx.delete(f"{URL}/api/v1/field/spool/multi_choice_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() + + +@pytest.mark.asyncio +async def test_filter_empty_custom_field(random_filament: dict[str, Any]): + """Test the filter returns items that have no value set for a custom field.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/optional_field", + json={"name": "Optional field", "field_type": "text"}, + ) + assert_httpx_success(result) + + # Spool 1 has the field set + result = httpx.post( + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"], "extra": {"optional_field": json.dumps("has_value")}}, + ) + assert_httpx_success(result) + spool_id1 = result.json()["id"] + + # Spool 2 does NOT have the field set + result = httpx.post( + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"]}, + ) + assert_httpx_success(result) + spool_id2 = result.json()["id"] + + # Filter by — spool 2 (no field row) should appear, spool 1 should not + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.optional_field": ""}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id2 in ids + assert spool_id1 not in ids + + # Filter by the value — spool 1 should appear, spool 2 should not + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.optional_field": "has_value"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id1 in ids + assert spool_id2 not in ids + + # Clean up + httpx.delete(f"{URL}/api/v1/field/spool/optional_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() + + +@pytest.mark.asyncio +async def test_filter_sort_filament_custom_field(random_filament: dict[str, Any]): + """Test filtering and sorting filaments by a custom field.""" + vendor_id = random_filament["vendor"]["id"] + + result = httpx.post( + f"{URL}/api/v1/field/filament/filament_tag", + json={"name": "Filament tag", "field_type": "text"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/filament", + json={"vendor_id": vendor_id, "density": 1.24, "diameter": 1.75, "extra": {"filament_tag": json.dumps("beta")}}, + ) + assert_httpx_success(result) + filament_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/filament", + json={"vendor_id": vendor_id, "density": 1.24, "diameter": 1.75, "extra": {"filament_tag": json.dumps("alpha")}}, + ) + assert_httpx_success(result) + filament_id2 = result.json()["id"] + + # Filter by custom field — only filament with "beta" should appear + result = httpx.get(f"{URL}/api/v1/filament", params={"extra.filament_tag": "beta"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert filament_id1 in ids + assert filament_id2 not in ids + + # Sort ascending: alpha before beta + result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.filament_tag:asc"}) + assert_httpx_success(result) + test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] + assert len(test_filaments) == 2 + assert test_filaments[0]["id"] == filament_id2 # alpha first + assert test_filaments[1]["id"] == filament_id1 # beta second + + # Sort descending: beta before alpha + result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.filament_tag:desc"}) + assert_httpx_success(result) + test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] + assert len(test_filaments) == 2 + assert test_filaments[0]["id"] == filament_id1 # beta first + assert test_filaments[1]["id"] == filament_id2 # alpha second + + # Clean up + httpx.delete(f"{URL}/api/v1/field/filament/filament_tag").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() + + +@pytest.mark.asyncio +async def test_filter_sort_vendor_custom_field(): + """Test filtering and sorting vendors by a custom field.""" + result = httpx.post( + f"{URL}/api/v1/field/vendor/vendor_tier", + json={"name": "Vendor tier", "field_type": "text"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor Gold", "extra": {"vendor_tier": json.dumps("gold")}}, + ) + assert_httpx_success(result) + vendor_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor Silver", "extra": {"vendor_tier": json.dumps("silver")}}, + ) + assert_httpx_success(result) + vendor_id2 = result.json()["id"] + + # Filter by vendor custom field — only gold vendor should appear + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.vendor_tier": "gold"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert vendor_id1 in ids + assert vendor_id2 not in ids + + # Sort ascending: gold before silver + result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.vendor_tier:asc"}) + assert_httpx_success(result) + test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] + assert len(test_vendors) == 2 + assert test_vendors[0]["id"] == vendor_id1 # gold first + assert test_vendors[1]["id"] == vendor_id2 # silver second + + # Sort descending: silver before gold + result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.vendor_tier:desc"}) + assert_httpx_success(result) + test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] + assert len(test_vendors) == 2 + assert test_vendors[0]["id"] == vendor_id2 # silver first + assert test_vendors[1]["id"] == vendor_id1 # gold second + + # Clean up + httpx.delete(f"{URL}/api/v1/field/vendor/vendor_tier").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() From cf697671fc401044c39ac679943de9058ea1a138 Mon Sep 17 00:00:00 2001 From: Donkie Date: Mon, 16 Mar 2026 20:08:29 +0100 Subject: [PATCH 04/16] trigger CI From a70ca7e9047ae6eb5eacf4fdbf61223cedb87002 Mon Sep 17 00:00:00 2001 From: akira69 Date: Thu, 26 Mar 2026 00:03:45 -0500 Subject: [PATCH 05/16] fix: address extra field filter review feedback --- spoolman/api/v1/filament.py | 31 ++- spoolman/api/v1/spool.py | 33 +-- spoolman/api/v1/vendor.py | 22 +- spoolman/database/extra_field_query.py | 246 ++++++++++++++++++ spoolman/database/filament.py | 91 +++---- spoolman/database/spool.py | 139 ++++------ spoolman/database/utils.py | 213 +-------------- spoolman/database/vendor.py | 81 ++---- spoolman/extra_field_registry.py | 196 ++++++++++++++ spoolman/extra_fields.py | 214 ++------------- .../tests/fields/test_filter_sort.py | 122 ++++----- 11 files changed, 688 insertions(+), 700 deletions(-) create mode 100644 spoolman/database/extra_field_query.py create mode 100644 spoolman/extra_field_registry.py diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index e10963fb7..0549f5dbc 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -351,20 +351,23 @@ async def find( field_key = key[6:] # Remove "extra." prefix extra_field_filters[field_key] = value - db_items, total_count = await filament.find( - db=db, - ids=filter_by_ids, - 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, - external_id=external_id, - extra_field_filters=extra_field_filters if extra_field_filters else None, - sort_by=sort_by, - limit=limit, - offset=offset, - ) + try: + db_items, total_count = await filament.find( + db=db, + ids=filter_by_ids, + 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, + external_id=external_id, + extra_field_filters=extra_field_filters if extra_field_filters else None, + sort_by=sort_by, + limit=limit, + offset=offset, + ) + except ValueError as e: + return JSONResponse(status_code=400, content=Message(message=str(e)).dict()) # Set x-total-count header for pagination return JSONResponse( diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index ef659a327..fa86b385b 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -294,21 +294,24 @@ async def find( field_key = key[6:] # Remove "extra." prefix extra_field_filters[field_key] = value - db_items, total_count = await spool.find( - db=db, - 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, - extra_field_filters=extra_field_filters if extra_field_filters else None, - sort_by=sort_by, - limit=limit, - offset=offset, - ) + try: + db_items, total_count = await spool.find( + db=db, + 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, + extra_field_filters=extra_field_filters if extra_field_filters else None, + sort_by=sort_by, + limit=limit, + offset=offset, + ) + except ValueError as e: + return JSONResponse(status_code=400, content=Message(message=str(e)).dict()) # Set x-total-count header for pagination return JSONResponse( diff --git a/spoolman/api/v1/vendor.py b/spoolman/api/v1/vendor.py index 54601228a..f9395a004 100644 --- a/spoolman/api/v1/vendor.py +++ b/spoolman/api/v1/vendor.py @@ -133,15 +133,19 @@ async def find( field_key = key[6:] # Remove "extra." prefix extra_field_filters[field_key] = value - db_items, total_count = await vendor.find( - db=db, - name=name, - external_id=external_id, - extra_field_filters=extra_field_filters if extra_field_filters else None, - sort_by=sort_by, - limit=limit, - offset=offset, - ) + try: + db_items, total_count = await vendor.find( + db=db, + name=name, + external_id=external_id, + extra_field_filters=extra_field_filters if extra_field_filters else None, + sort_by=sort_by, + limit=limit, + offset=offset, + ) + except ValueError as e: + return JSONResponse(status_code=400, content=Message(message=str(e)).dict()) + # Set x-total-count header for pagination return JSONResponse( content=jsonable_encoder( diff --git a/spoolman/database/extra_field_query.py b/spoolman/database/extra_field_query.py new file mode 100644 index 000000000..547efeeb6 --- /dev/null +++ b/spoolman/database/extra_field_query.py @@ -0,0 +1,246 @@ +"""Helpers for filtering and sorting extra fields.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import sqlalchemy +from sqlalchemy import Select + +from spoolman.database import models +from spoolman.database.utils import SortOrder +from spoolman.extra_field_registry import EntityType, ExtraField, ExtraFieldType, get_extra_fields + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + from sqlalchemy.orm.attributes import InstrumentedAttribute + + +def _get_field_table_for_entity(entity_type: EntityType) -> type[models.Base]: + """Map an entity type to its extra-field table.""" + if entity_type == EntityType.spool: + return models.SpoolField + if entity_type == EntityType.filament: + return models.FilamentField + if entity_type == EntityType.vendor: + return models.VendorField + raise ValueError(f"Unknown entity type: {entity_type}") + + +def _get_entity_id_column(field_table: type[models.Base]) -> InstrumentedAttribute[int]: + """Map an extra-field table to its owning entity id column.""" + if field_table == models.SpoolField: + return models.SpoolField.spool_id + if field_table == models.FilamentField: + return models.FilamentField.filament_id + if field_table == models.VendorField: + return models.VendorField.vendor_id + raise ValueError(f"Unknown field table: {field_table}") + + +def _parse_boolean_filter(value: str) -> bool: + """Parse a boolean filter using the same loose-but-explicit semantics as the API.""" + normalized = value.strip().lower() + if normalized in {"true", "1", "yes"}: + return True + if normalized in {"false", "0", "no"}: + return False + raise ValueError(f"Invalid boolean filter value: {value}") + + +async def apply_extra_field_filters_and_sort( + *, + db: AsyncSession, + stmt: Select, + base_obj: type[models.Base], + entity_type: EntityType, + extra_field_filters: dict[str, str] | None, + sort_by: dict[str, SortOrder] | None, +) -> Select: + """Apply extra-field filtering and sorting to a query.""" + if not extra_field_filters and not (sort_by is not None and any(field.startswith("extra.") for field in sort_by)): + return stmt + + extra_fields = await get_extra_fields(db, entity_type) + extra_fields_dict: dict[str, ExtraField] = {field.key: field for field in extra_fields} + + if extra_field_filters: + for field_key, value in extra_field_filters.items(): + field = extra_fields_dict.get(field_key) + if field is None: + continue + stmt = add_where_clause_extra_field( + stmt=stmt, + base_obj=base_obj, + entity_type=entity_type, + field_key=field_key, + field_type=field.field_type, + value=value, + multi_choice=field.multi_choice if field.field_type == ExtraFieldType.choice else None, + ) + + if sort_by is not None: + for field_name, order in sort_by.items(): + if not field_name.startswith("extra."): + continue + + field_key = field_name[6:] + extra_field = extra_fields_dict.get(field_key) + if extra_field is None: + continue + + stmt = add_order_by_extra_field( + stmt=stmt, + base_obj=base_obj, + entity_type=entity_type, + field_key=field_key, + field_type=extra_field.field_type, + order=order, + ) + + return stmt + + +def add_where_clause_extra_field( # noqa: C901, PLR0912, PLR0915 + stmt: Select, + base_obj: type[models.Base], + entity_type: EntityType, + field_key: str, + field_type: ExtraFieldType, + value: str, + *, + multi_choice: bool | None = None, +) -> Select: + """Add a where clause to a select statement for an extra field.""" + field_table = _get_field_table_for_entity(entity_type) + entity_id_column = _get_entity_id_column(field_table) + base_id_column = base_obj.id + + conditions = [] + for value_part in value.split(","): + # Empty-string filters follow the existing string-query API semantics. + if len(value_part) == 0: + empty_conditions = [ + field_table.value.is_(None), + field_table.value == "null", + ] + if field_type == ExtraFieldType.boolean: + empty_conditions.append(field_table.value == json.dumps(bool(0))) + + field_has_empty_value = sqlalchemy.select(entity_id_column).where( + sqlalchemy.and_(field_table.key == field_key, sqlalchemy.or_(*empty_conditions)) + ) + field_missing_entirely = sqlalchemy.select(base_id_column).where( + base_id_column.not_in(sqlalchemy.select(entity_id_column).where(field_table.key == field_key)) + ) + conditions.append(base_id_column.in_(field_has_empty_value)) + conditions.append(base_id_column.in_(field_missing_entirely)) + continue + + exact_match = value_part.startswith('"') and value_part.endswith('"') + parsed_value = value_part[1:-1] if exact_match else value_part + + if field_type == ExtraFieldType.text: + field_condition = ( + field_table.value == json.dumps(parsed_value) + if exact_match + else field_table.value.ilike(f"%{parsed_value}%") + ) + elif field_type == ExtraFieldType.integer: + try: + field_condition = field_table.value == json.dumps(int(parsed_value)) + except ValueError as exc: + raise ValueError(f"Invalid integer filter value for '{field_key}': {parsed_value}") from exc + elif field_type == ExtraFieldType.float: + try: + field_condition = field_table.value == json.dumps(float(parsed_value)) + except ValueError as exc: + raise ValueError(f"Invalid float filter value for '{field_key}': {parsed_value}") from exc + elif field_type == ExtraFieldType.boolean: + field_condition = field_table.value == json.dumps(_parse_boolean_filter(parsed_value)) + elif field_type == ExtraFieldType.choice: + if multi_choice: + field_condition = field_table.value.like(f'%"{parsed_value}"%') + else: + field_condition = field_table.value == json.dumps(parsed_value) + elif field_type == ExtraFieldType.datetime: + field_condition = field_table.value == json.dumps(parsed_value) + elif field_type in (ExtraFieldType.integer_range, ExtraFieldType.float_range): + if ":" not in parsed_value: + raise ValueError( + f"Invalid range filter value for '{field_key}': {parsed_value}. Expected ':'." + ) + min_val_str, max_val_str = parsed_value.split(":", 1) + converter = int if field_type == ExtraFieldType.integer_range else float + range_conditions = [] + try: + if min_val_str: + range_field = field_table.value[0] + cast_type = sqlalchemy.Integer if field_type == ExtraFieldType.integer_range else sqlalchemy.Float + range_conditions.append(sqlalchemy.cast(range_field, cast_type) >= converter(min_val_str)) + if max_val_str: + range_field = field_table.value[1] + cast_type = sqlalchemy.Integer if field_type == ExtraFieldType.integer_range else sqlalchemy.Float + range_conditions.append(sqlalchemy.cast(range_field, cast_type) <= converter(max_val_str)) + except (ValueError, TypeError) as exc: + range_kind = "integer" if field_type == ExtraFieldType.integer_range else "float" + raise ValueError(f"Invalid {range_kind} range filter value for '{field_key}': {parsed_value}") from exc + if not range_conditions: + raise ValueError( + f"Invalid range filter value for '{field_key}': {parsed_value}. Expected ':'." + ) + field_condition = sqlalchemy.and_(*range_conditions) + else: + raise ValueError(f"Unsupported extra field type for '{field_key}': {field_type}") + + matching_entities = sqlalchemy.select(entity_id_column).where( + sqlalchemy.and_(field_table.key == field_key, field_condition) + ) + conditions.append(base_id_column.in_(matching_entities)) + + if not conditions: + return stmt + + return stmt.where(sqlalchemy.or_(*conditions)) + + +def add_order_by_extra_field( + stmt: Select, + base_obj: type[models.Base], + entity_type: EntityType, + field_key: str, + field_type: ExtraFieldType, + order: SortOrder, +) -> Select: + """Add an order-by clause to a select statement for an extra field.""" + field_table = _get_field_table_for_entity(entity_type) + entity_id_column = _get_entity_id_column(field_table) + + value_subq = ( + sqlalchemy.select(field_table.value) + .where( + sqlalchemy.and_( + field_table.key == field_key, + entity_id_column == base_obj.id, + ) + ) + .scalar_subquery() + .correlate(base_obj) + ) + + if field_type == ExtraFieldType.integer: + sort_expr = sqlalchemy.cast(value_subq, sqlalchemy.Integer) + elif field_type == ExtraFieldType.float: + sort_expr = sqlalchemy.cast(value_subq, sqlalchemy.Float) + elif field_type in (ExtraFieldType.integer_range, ExtraFieldType.float_range): + sort_expr = sqlalchemy.cast( + value_subq[0], + sqlalchemy.Integer if field_type == ExtraFieldType.integer_range else sqlalchemy.Float, + ) + else: + sort_expr = value_subq + + if order == SortOrder.ASC: + return stmt.order_by(sort_expr.asc()) + return stmt.order_by(sort_expr.desc()) diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index 130981ae8..7c8548b80 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -12,8 +12,17 @@ from spoolman.api.v1.models import EventType, Filament, FilamentEvent, MultiColorDirection from spoolman.database import models, vendor -from spoolman.database.utils import SortOrder +from spoolman.database.extra_field_query import apply_extra_field_filters_and_sort +from spoolman.database.utils import ( + SortOrder, + add_where_clause_int_in, + add_where_clause_int_opt, + add_where_clause_str, + add_where_clause_str_opt, + parse_nested_field, +) from spoolman.exceptions import ItemDeleteError, ItemNotFoundError +from spoolman.extra_field_registry import EntityType from spoolman.math import delta_e, hex_to_rgb, rgb_to_lab from spoolman.ws import websocket_manager @@ -107,17 +116,6 @@ async def find( Returns a tuple containing the list of items and the total count of matching items. """ - # Import here to avoid circular imports - from spoolman.database.utils import ( - add_where_clause_int_in, - add_where_clause_int_opt, - add_where_clause_str, - add_where_clause_str_opt, - add_where_clause_extra_field, - add_order_by_extra_field, - parse_nested_field, - ) - stmt = ( select(models.Filament) .options(contains_eager(models.Filament.vendor)) @@ -134,60 +132,31 @@ 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) - - # Apply extra field filters if provided - if extra_field_filters: - # Get all extra fields for filaments - from spoolman.extra_fields import EntityType, ExtraFieldType, get_extra_fields - - extra_fields = await get_extra_fields(db, EntityType.filament) - extra_fields_dict = {field.key: field for field in extra_fields} - - for field_key, value in extra_field_filters.items(): - if field_key in extra_fields_dict: - field = extra_fields_dict[field_key] - stmt = add_where_clause_extra_field( - stmt, - models.Filament, - EntityType.filament, - field_key, - field.field_type, - value, - field.multi_choice if field.field_type == ExtraFieldType.choice else None, - ) + stmt = await apply_extra_field_filters_and_sort( + db=db, + stmt=stmt, + base_obj=models.Filament, + entity_type=EntityType.filament, + extra_field_filters=extra_field_filters, + sort_by=sort_by, + ) if sort_by is not None: for fieldstr, order in sort_by.items(): # Check if this is a custom field sort if fieldstr.startswith("extra."): - field_key = fieldstr[6:] # Remove "extra." prefix - - # Get the field definition - from spoolman.extra_fields import EntityType, get_extra_fields - - extra_fields = await get_extra_fields(db, EntityType.filament) - extra_field = next((f for f in extra_fields if f.key == field_key), None) - - if extra_field: - stmt = add_order_by_extra_field( - stmt, - models.Filament, - EntityType.filament, - field_key, - extra_field.field_type, - order - ) - else: - 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()) + continue + + 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()) + + if limit is not None: + total_count_stmt = stmt.with_only_columns(func.count(), maintain_column_froms=True).order_by(None) + total_count = (await db.execute(total_count_stmt)).scalar() + stmt = stmt.offset(offset).limit(limit) rows = await db.execute( stmt, diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 89c58b291..f1bb25954 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -13,8 +13,17 @@ from spoolman.api.v1.models import EventType, Spool, SpoolEvent from spoolman.database import filament, models -from spoolman.database.utils import SortOrder +from spoolman.database.extra_field_query import apply_extra_field_filters_and_sort +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, SpoolMeasureError +from spoolman.extra_field_registry import EntityType from spoolman.math import weight_from_length from spoolman.ws import websocket_manager @@ -127,17 +136,6 @@ async def find( # noqa: C901, PLR0912 Returns a tuple containing the list of items and the total count of matching items. """ - # Import here to avoid circular imports - from spoolman.database.utils import ( - add_where_clause_int, - add_where_clause_int_opt, - add_where_clause_str, - add_where_clause_str_opt, - add_where_clause_extra_field, - add_order_by_extra_field, - parse_nested_field, - ) - stmt = ( sqlalchemy.select(models.Spool) .join(models.Spool.filament, isouter=True) @@ -164,84 +162,57 @@ async def find( # noqa: C901, PLR0912 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) - - # Apply extra field filters if provided - if extra_field_filters: - # Get all extra fields for spools - from spoolman.extra_fields import EntityType, ExtraFieldType, get_extra_fields - - extra_fields = await get_extra_fields(db, EntityType.spool) - extra_fields_dict = {field.key: field for field in extra_fields} - - for field_key, value in extra_field_filters.items(): - if field_key in extra_fields_dict: - field = extra_fields_dict[field_key] - stmt = add_where_clause_extra_field( - stmt, - models.Spool, - EntityType.spool, - field_key, - field.field_type, - value, - field.multi_choice if field.field_type == ExtraFieldType.choice else None, - ) + stmt = await apply_extra_field_filters_and_sort( + db=db, + stmt=stmt, + base_obj=models.Spool, + entity_type=EntityType.spool, + extra_field_filters=extra_field_filters, + sort_by=sort_by, + ) if sort_by is not None: for fieldstr, order in sort_by.items(): # Check if this is a custom field sort if fieldstr.startswith("extra."): - field_key = fieldstr[6:] # Remove "extra." prefix - - # Get the field definition - from spoolman.extra_fields import EntityType, get_extra_fields - - extra_fields = await get_extra_fields(db, EntityType.spool) - extra_field = next((f for f in extra_fields if f.key == field_key), None) - - if extra_field: - stmt = add_order_by_extra_field( - stmt, - models.Spool, - EntityType.spool, - field_key, - extra_field.field_type, - order - ) + continue + + sorts = [] + if fieldstr == "remaining_weight": + sorts.append( + coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight, + ) + elif fieldstr == "remaining_length": + # Simplified weight -> length formula. Absolute value is not correct but the proportionality + # is still kept, which means the sort order is correct. + sorts.append( + (coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight) + / models.Filament.density + / (models.Filament.diameter * models.Filament.diameter), + ) + elif fieldstr == "used_length": + sorts.append( + models.Spool.used_weight + / models.Filament.density + / (models.Filament.diameter * models.Filament.diameter), + ) + elif fieldstr == "filament.combined_name": + sorts.append(models.Vendor.name) + sorts.append(models.Filament.name) + elif fieldstr == "price": + sorts.append(coalesce(models.Spool.price, models.Filament.price)) else: - sorts = [] - if fieldstr == "remaining_weight": - sorts.append(coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight) - elif fieldstr == "remaining_length": - # Simplified weight -> length formula. Absolute value is not correct but the proportionality is still - # kept, which means the sort order is correct. - sorts.append( - (coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight) - / models.Filament.density - / (models.Filament.diameter * models.Filament.diameter), - ) - elif fieldstr == "used_length": - sorts.append( - models.Spool.used_weight - / models.Filament.density - / (models.Filament.diameter * models.Filament.diameter), - ) - elif fieldstr == "filament.combined_name": - sorts.append(models.Vendor.name) - sorts.append(models.Filament.name) - elif fieldstr == "price": - sorts.append(coalesce(models.Spool.price, models.Filament.price)) - else: - sorts.append(parse_nested_field(models.Spool, fieldstr)) - - if order == SortOrder.ASC: - stmt = stmt.order_by(*(f.asc() for f in sorts)) - elif order == SortOrder.DESC: - stmt = stmt.order_by(*(f.desc() for f in sorts)) + sorts.append(parse_nested_field(models.Spool, fieldstr)) + + if order == SortOrder.ASC: + stmt = stmt.order_by(*(f.asc() for f in sorts)) + elif order == SortOrder.DESC: + stmt = stmt.order_by(*(f.desc() for f in sorts)) + + if limit is not None: + total_count_stmt = stmt.with_only_columns(func.count(), maintain_column_froms=True).order_by(None) + total_count = (await db.execute(total_count_stmt)).scalar() + stmt = stmt.offset(offset).limit(limit) rows = await db.execute( stmt, diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py index 54b96c739..2d8776c00 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -1,9 +1,8 @@ """Utility functions for the database module.""" from collections.abc import Sequence -import json from enum import Enum -from typing import Any, Type, TypeVar +from typing import Any, TypeVar import sqlalchemy from sqlalchemy import Select @@ -130,213 +129,3 @@ def add_where_clause_int_in( if value is not None: stmt = stmt.where(field.in_(value)) return stmt - - -def get_field_table_for_entity(entity_type: Any) -> Type[models.Base]: - """Get the field table class for a given entity type.""" - # Import here to avoid circular imports - from spoolman.extra_fields import EntityType - - if entity_type == EntityType.spool: - return models.SpoolField - elif entity_type == EntityType.filament: - return models.FilamentField - elif entity_type == EntityType.vendor: - return models.VendorField - else: - raise ValueError(f"Unknown entity type: {entity_type}") - - -def get_entity_id_column(field_table: Type[models.Base]) -> attributes.InstrumentedAttribute[int]: - """Get the entity ID column for a given field table.""" - if field_table == models.SpoolField: - return models.SpoolField.spool_id - elif field_table == models.FilamentField: - return models.FilamentField.filament_id - elif field_table == models.VendorField: - return models.VendorField.vendor_id - else: - raise ValueError(f"Unknown field table: {field_table}") - - -def add_where_clause_extra_field( - stmt: Select, - base_obj: Type[models.Base], - entity_type: Any, - field_key: str, - field_type: Any, - value: str, - multi_choice: bool | None = None, -) -> Select: - """Add a where clause to a select statement for an extra field. - Args: - stmt: The select statement to add the where clause to - base_obj: The base object type (Spool, Filament, Vendor) - entity_type: The entity type - field_key: The key of the extra field - field_type: The type of the extra field - value: The value to filter by - multi_choice: Whether the field is a multi-choice field (only for choice fields) - Returns: - The modified select statement - """ - # Import here to avoid circular imports - from spoolman.extra_fields import ExtraFieldType - - field_table = get_field_table_for_entity(entity_type) - entity_id_column = get_entity_id_column(field_table) - - value_parts = value.split(",") - - # Handle filtering for empty values - if any(p == "" or len(p) == 0 for p in value_parts): - # An item is considered "empty" if: - # A) A row exists in the field table, and its value is null, 'null', or 'false' for booleans. - # B) No row exists in the field table for this item and field_key. - - # Condition A subquery - empty_conditions = [ - field_table.value.is_(None), - field_table.value == "null", - ] - if field_type == ExtraFieldType.boolean: - empty_conditions.append(field_table.value == json.dumps(False)) - - subq_a = sqlalchemy.select(entity_id_column).where( - sqlalchemy.and_(field_table.key == field_key, sqlalchemy.or_(*empty_conditions)) - ) - - # Condition B subquery - subq_b = sqlalchemy.select(base_obj.id).where( - getattr(base_obj, "id").not_in(sqlalchemy.select(entity_id_column).where(field_table.key == field_key)) - ) - - return stmt.where( - sqlalchemy.or_( - getattr(base_obj, "id").in_(subq_a), - getattr(base_obj, "id").in_(subq_b), - ) - ) - - # Handle filtering for specific values - conditions = [] - for value_part in value_parts: - exact_match = value_part.startswith('"') and value_part.endswith('"') - if exact_match: - value_part = value_part[1:-1] - - if field_type == ExtraFieldType.text: - if exact_match: - conditions.append(field_table.value == json.dumps(value_part)) - else: - conditions.append(field_table.value.ilike(f"%{value_part}%")) - elif field_type == ExtraFieldType.integer: - try: - conditions.append(field_table.value == json.dumps(int(value_part))) - except ValueError: - pass - elif field_type == ExtraFieldType.float: - try: - conditions.append(field_table.value == json.dumps(float(value_part))) - except ValueError: - pass - elif field_type == ExtraFieldType.boolean: - bool_value = value_part.lower() in ("true", "1", "yes") - conditions.append(field_table.value == json.dumps(bool_value)) - elif field_type == ExtraFieldType.choice: - if multi_choice: - conditions.append(field_table.value.like(f'%"{value_part}"%')) - else: - conditions.append(field_table.value == json.dumps(value_part)) - elif field_type == ExtraFieldType.datetime: - conditions.append(field_table.value == json.dumps(value_part)) - elif field_type in (ExtraFieldType.integer_range, ExtraFieldType.float_range): - if ":" in value_part: - min_val_str, max_val_str = value_part.split(":", 1) - converter = int if field_type == ExtraFieldType.integer_range else float - try: - if min_val_str: - conditions.append(field_table.value[0].as_integer() >= converter(min_val_str)) - if max_val_str: - conditions.append(field_table.value[1].as_integer() <= converter(max_val_str)) - except (ValueError, TypeError): - pass - - if not conditions: - return stmt - - subq = sqlalchemy.select(entity_id_column).where( - sqlalchemy.and_(field_table.key == field_key, sqlalchemy.or_(*conditions)) - ) - - return stmt.where(getattr(base_obj, "id").in_(subq)) - - -def add_order_by_extra_field( - stmt: Select, - base_obj: Type[models.Base], - entity_type: Any, - field_key: str, - field_type: Any, - order: SortOrder, -) -> Select: - """Add an order by clause to a select statement for an extra field. - - Args: - stmt: The select statement to add the order by clause to - base_obj: The base object type (Spool, Filament, Vendor) - entity_type: The entity type - field_key: The key of the extra field - field_type: The type of the extra field - order: The sort order - - Returns: - The modified select statement - """ - # Import here to avoid circular imports - from spoolman.extra_fields import EntityType, ExtraFieldType - - # Use a subquery approach instead of joins - field_table = get_field_table_for_entity(entity_type) - entity_id_column = get_entity_id_column(field_table) - - # Create a subquery that selects the value for each entity - value_subq = ( - sqlalchemy.select(field_table.value) - .where( - sqlalchemy.and_( - field_table.key == field_key, - entity_id_column == getattr(base_obj, "id") - ) - ) - .scalar_subquery() - .correlate(base_obj) - ) - - # Create a sort expression based on the field type - if field_type == ExtraFieldType.integer: - # Cast the JSON value to an integer for sorting - sort_expr = sqlalchemy.cast(value_subq, sqlalchemy.Integer) - elif field_type == ExtraFieldType.float: - # Cast the JSON value to a float for sorting - sort_expr = sqlalchemy.cast(value_subq, sqlalchemy.Float) - elif field_type == ExtraFieldType.datetime: - # For datetime fields, we can sort by the ISO string - sort_expr = value_subq - elif field_type == ExtraFieldType.boolean: - # For boolean fields, true comes after false - sort_expr = value_subq - elif field_type in (ExtraFieldType.integer_range, ExtraFieldType.float_range): - # For range fields, sort by the first value in the range - sort_expr = value_subq[0] - else: - # For text and choice fields, sort by the string value - sort_expr = value_subq - - # Apply the sort order - if order == SortOrder.ASC: - stmt = stmt.order_by(sort_expr.asc()) - else: - stmt = stmt.order_by(sort_expr.desc()) - - return stmt diff --git a/spoolman/database/vendor.py b/spoolman/database/vendor.py index aa84a7c3d..f3b3358b9 100644 --- a/spoolman/database/vendor.py +++ b/spoolman/database/vendor.py @@ -9,8 +9,10 @@ from spoolman.api.v1.models import EventType, Vendor, VendorEvent from spoolman.database import models -from spoolman.database.utils import SortOrder +from spoolman.database.extra_field_query import apply_extra_field_filters_and_sort +from spoolman.database.utils import SortOrder, add_where_clause_str, add_where_clause_str_opt from spoolman.exceptions import ItemNotFoundError +from spoolman.extra_field_registry import EntityType from spoolman.ws import websocket_manager logger = logging.getLogger(__name__) @@ -62,14 +64,6 @@ async def find( Returns a tuple containing the list of items and the total count of matching items. """ - # Import here to avoid circular imports - from spoolman.database.utils import ( - add_where_clause_str, - add_where_clause_str_opt, - add_where_clause_extra_field, - add_order_by_extra_field - ) - stmt = select(models.Vendor) stmt = add_where_clause_str(stmt, models.Vendor.name, name) @@ -77,60 +71,31 @@ 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) - - # Apply extra field filters if provided - if extra_field_filters: - # Get all extra fields for vendors - from spoolman.extra_fields import EntityType, ExtraFieldType, get_extra_fields - - extra_fields = await get_extra_fields(db, EntityType.vendor) - extra_fields_dict = {field.key: field for field in extra_fields} - - for field_key, value in extra_field_filters.items(): - if field_key in extra_fields_dict: - field = extra_fields_dict[field_key] - stmt = add_where_clause_extra_field( - stmt, - models.Vendor, - EntityType.vendor, - field_key, - field.field_type, - value, - field.multi_choice if field.field_type == ExtraFieldType.choice else None, - ) + stmt = await apply_extra_field_filters_and_sort( + db=db, + stmt=stmt, + base_obj=models.Vendor, + entity_type=EntityType.vendor, + extra_field_filters=extra_field_filters, + sort_by=sort_by, + ) if sort_by is not None: for fieldstr, order in sort_by.items(): # Check if this is a custom field sort if fieldstr.startswith("extra."): - field_key = fieldstr[6:] # Remove "extra." prefix - - # Get the field definition - from spoolman.extra_fields import EntityType, get_extra_fields - - extra_fields = await get_extra_fields(db, EntityType.vendor) - extra_field = next((f for f in extra_fields if f.key == field_key), None) - - if extra_field: - stmt = add_order_by_extra_field( - stmt, - models.Vendor, - EntityType.vendor, - field_key, - extra_field.field_type, - order - ) - else: - 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()) + continue + + 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()) + + if limit is not None: + total_count_stmt = stmt.with_only_columns(func.count(), maintain_column_froms=True).order_by(None) + total_count = (await db.execute(total_count_stmt)).scalar() + stmt = stmt.offset(offset).limit(limit) rows = await db.execute( stmt, diff --git a/spoolman/extra_field_registry.py b/spoolman/extra_field_registry.py new file mode 100644 index 000000000..173b2420a --- /dev/null +++ b/spoolman/extra_field_registry.py @@ -0,0 +1,196 @@ +"""Shared extra-field definitions and settings access.""" + +from __future__ import annotations + +import json +import logging +from enum import Enum +from typing import TYPE_CHECKING + +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel, Field + +from spoolman.database import setting as db_setting +from spoolman.exceptions import ItemNotFoundError +from spoolman.settings import parse_setting + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + + +class EntityType(Enum): + vendor = "vendor" + filament = "filament" + spool = "spool" + + +class ExtraFieldType(Enum): + text = "text" + integer = "integer" + integer_range = "integer_range" + float = "float" + float_range = "float_range" + datetime = "datetime" + boolean = "boolean" + choice = "choice" + + +class ExtraFieldParameters(BaseModel): + name: str = Field(description="Nice name", min_length=1, max_length=128) + order: int = Field(0, description="Order of the field") + unit: str | None = Field(None, description="Unit of the value", min_length=1, max_length=16) + field_type: ExtraFieldType = Field(description="Type of the field") + default_value: str | None = Field(None, description="Default value of the field") + choices: list[str] | None = Field( + None, + description="Choices for the field, only for field type choice", + min_length=1, + ) + multi_choice: bool | None = Field(None, description="Whether multiple choices can be selected") + + +class ExtraField(ExtraFieldParameters): + key: str = Field(description="Unique key", pattern="^[a-z0-9_]+$", min_length=1, max_length=64) + entity_type: EntityType = Field(description="Entity type this field is for") + + +def validate_extra_field_value(field: ExtraFieldParameters, value: str) -> None: # noqa: C901, PLR0912 + """Validate that the value has the correct type.""" + try: + data = json.loads(value) + except json.JSONDecodeError: + raise ValueError("Value is not valid JSON.") from None + + if field.field_type == ExtraFieldType.text: + if not isinstance(data, str): + raise ValueError("Value is not a string.") + elif field.field_type == ExtraFieldType.integer: + if not isinstance(data, int): + raise ValueError("Value is not an integer.") + elif field.field_type == ExtraFieldType.integer_range: + if not isinstance(data, list): + raise ValueError("Value is not a list.") + if len(data) != 2: # noqa: PLR2004 + raise ValueError("Value list must have exactly two values.") + if not all(isinstance(item, int) or item is None for item in data): + raise ValueError("Value list must contain only integers or null.") + elif field.field_type == ExtraFieldType.float: + if not isinstance(data, (float, int)) or isinstance(data, bool): + raise ValueError("Value is not a float.") + elif field.field_type == ExtraFieldType.float_range: + if not isinstance(data, list): + raise ValueError("Value is not a list.") + if len(data) != 2: # noqa: PLR2004 + raise ValueError("Value list must have exactly two values.") + if not all((isinstance(item, (float, int)) or item is None) and not isinstance(item, bool) for item in data): + raise ValueError("Value list must contain only floats or null.") + elif field.field_type == ExtraFieldType.datetime: + if not isinstance(data, str): + raise ValueError("Value is not a string.") + elif field.field_type == ExtraFieldType.boolean: + if not isinstance(data, bool): + raise ValueError("Value is not a boolean.") + elif field.field_type == ExtraFieldType.choice: + if field.multi_choice: + if not isinstance(data, list): + raise ValueError("Value is not a list.") + if not all(isinstance(item, str) for item in data): + raise ValueError("Value list must contain only strings.") + if field.choices is not None and not all(item in field.choices for item in data): + raise ValueError("Value list contains invalid choices.") + else: + if not isinstance(data, str): + raise ValueError("Value is not a string.") + if field.choices is not None and data not in field.choices: + raise ValueError("Value is not a valid choice.") + else: + raise ValueError(f"Unknown field type {field.field_type}.") + + +def validate_extra_field(field: ExtraFieldParameters) -> None: + """Validate an extra field.""" + if field.field_type == ExtraFieldType.choice: + if field.choices is None: + raise ValueError("Choices must be set for field type choice.") + if field.multi_choice is None: + raise ValueError("Multi choice must be set for field type choice.") + else: + if field.choices is not None: + raise ValueError("Choices must not be set for field type other than choice.") + if field.multi_choice is not None: + raise ValueError("Multi choice must not be set for field type other than choice.") + + if field.default_value is not None: + try: + validate_extra_field_value(field, field.default_value) + except ValueError as e: + raise ValueError(f"Default value is not valid: {e}") from None + + +def validate_extra_field_dict(all_fields: list[ExtraField], fields_input: dict[str, str]) -> None: + """Validate a dict of extra fields.""" + all_field_lookup = {field.key: field for field in all_fields} + for key, value in fields_input.items(): + if key not in all_field_lookup: + raise ValueError(f"Unknown extra field {key}.") + field = all_field_lookup[key] + try: + validate_extra_field_value(field, value) + except ValueError as e: + raise ValueError(f"Invalid extra field for key {key}: {e!s}") from None + + +extra_field_cache: dict[EntityType, list[ExtraField]] = {} + + +async def get_extra_fields(db: AsyncSession, entity_type: EntityType) -> list[ExtraField]: + """Get all extra fields for a specific entity type.""" + if entity_type in extra_field_cache: + return extra_field_cache[entity_type] + + setting_def = parse_setting(f"extra_fields_{entity_type.name}") + try: + setting = await db_setting.get(db, setting_def) + setting_value = setting.value + except ItemNotFoundError: + setting_value = setting_def.default + + setting_array = json.loads(setting_value) + if not isinstance(setting_array, list): + logger.warning("Setting %s is not a list, using default.", setting_def.key) + setting_array = [] + + fields = [ExtraField.parse_obj(obj) for obj in setting_array] + extra_field_cache[entity_type] = fields + return fields + + +async def add_or_update_extra_field(db: AsyncSession, entity_type: EntityType, extra_field: ExtraField) -> None: + """Add or update an extra field for a specific entity type.""" + validate_extra_field(extra_field) + + extra_fields = await get_extra_fields(db, entity_type) + existing_field = next((field for field in extra_fields if field.key == extra_field.key), None) + if existing_field is not None: + if existing_field.field_type != extra_field.field_type: + raise ValueError("Field type cannot be changed.") + if extra_field.field_type == ExtraFieldType.choice: + if existing_field.multi_choice != extra_field.multi_choice: + raise ValueError("Multi choice cannot be changed.") + if ( + existing_field.choices is not None + and extra_field.choices is not None + and not all(choice in extra_field.choices for choice in existing_field.choices) + ): + raise ValueError("Cannot remove existing choices.") + + extra_fields = [field for field in extra_fields if field.key != extra_field.key] + extra_fields.append(extra_field) + + setting_def = parse_setting(f"extra_fields_{entity_type.name}") + await db_setting.update(db=db, definition=setting_def, value=json.dumps(jsonable_encoder(extra_fields))) + + extra_field_cache[entity_type] = extra_fields + logger.info("Added/updated extra field %s for entity type %s.", extra_field.key, entity_type.name) diff --git a/spoolman/extra_fields.py b/spoolman/extra_fields.py index 2be157e3d..958acc900 100644 --- a/spoolman/extra_fields.py +++ b/spoolman/extra_fields.py @@ -2,10 +2,8 @@ import json import logging -from enum import Enum from fastapi.encoders import jsonable_encoder -from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from spoolman.database import filament as db_filament @@ -13,196 +11,36 @@ from spoolman.database import spool as db_spool from spoolman.database import vendor as db_vendor from spoolman.exceptions import ItemNotFoundError +from spoolman.extra_field_registry import ( + EntityType, + ExtraField, + ExtraFieldParameters, + ExtraFieldType, + add_or_update_extra_field, + extra_field_cache, + get_extra_fields, + validate_extra_field, + validate_extra_field_dict, + validate_extra_field_value, +) from spoolman.settings import parse_setting logger = logging.getLogger(__name__) - -class EntityType(Enum): - vendor = "vendor" - filament = "filament" - spool = "spool" - - -class ExtraFieldType(Enum): - text = "text" - integer = "integer" - integer_range = "integer_range" - float = "float" - float_range = "float_range" - datetime = "datetime" - boolean = "boolean" - choice = "choice" - - -class ExtraFieldParameters(BaseModel): - name: str = Field(description="Nice name", min_length=1, max_length=128) - order: int = Field(0, description="Order of the field") - unit: str | None = Field(None, description="Unit of the value", min_length=1, max_length=16) - field_type: ExtraFieldType = Field(description="Type of the field") - default_value: str | None = Field(None, description="Default value of the field") - choices: list[str] | None = Field( - None, - description="Choices for the field, only for field type choice", - min_length=1, - ) - multi_choice: bool | None = Field(None, description="Whether multiple choices can be selected") - - -class ExtraField(ExtraFieldParameters): - key: str = Field(description="Unique key", pattern="^[a-z0-9_]+$", min_length=1, max_length=64) - entity_type: EntityType = Field(description="Entity type this field is for") - - -def validate_extra_field_value(field: ExtraFieldParameters, value: str) -> None: # noqa: C901, PLR0912 - """Validate that the value has the correct type.""" - try: - data = json.loads(value) - except json.JSONDecodeError: - raise ValueError("Value is not valid JSON.") from None - - if field.field_type == ExtraFieldType.text: - if not isinstance(data, str): - raise ValueError("Value is not a string.") - elif field.field_type == ExtraFieldType.integer: - if not isinstance(data, int): - raise ValueError("Value is not an integer.") - elif field.field_type == ExtraFieldType.integer_range: - if not isinstance(data, list): - raise ValueError("Value is not a list.") - if len(data) != 2: # noqa: PLR2004 - raise ValueError("Value list must have exactly two values.") - if not all(isinstance(value, int) or value is None for value in data): - raise ValueError("Value list must contain only integers or null.") - elif field.field_type == ExtraFieldType.float: - if not isinstance(data, (float, int)) or isinstance(data, bool): - raise ValueError("Value is not a float.") - elif field.field_type == ExtraFieldType.float_range: - if not isinstance(data, list): - raise ValueError("Value is not a list.") - if len(data) != 2: # noqa: PLR2004 - raise ValueError("Value list must have exactly two values.") - if not all( - (isinstance(value, (float, int)) or value is None) and not isinstance(value, bool) for value in data - ): - raise ValueError("Value list must contain only floats or null.") - elif field.field_type == ExtraFieldType.datetime: - if not isinstance(data, str): - raise ValueError("Value is not a string.") - elif field.field_type == ExtraFieldType.boolean: - if not isinstance(data, bool): - raise ValueError("Value is not a boolean.") - elif field.field_type == ExtraFieldType.choice: - if field.multi_choice: - if not isinstance(data, list): - raise ValueError("Value is not a list.") - if not all(isinstance(value, str) for value in data): - raise ValueError("Value list must contain only strings.") - if field.choices is not None and not all(value in field.choices for value in data): - raise ValueError("Value list contains invalid choices.") - else: - if not isinstance(data, str): - raise ValueError("Value is not a string.") - if field.choices is not None and data not in field.choices: - raise ValueError("Value is not a valid choice.") - else: - raise ValueError(f"Unknown field type {field.field_type}.") - - -def validate_extra_field(field: ExtraFieldParameters) -> None: - """Validate an extra field.""" - # Validate choices exist if field type is choice - if field.field_type == ExtraFieldType.choice: - if field.choices is None: - raise ValueError("Choices must be set for field type choice.") - if field.multi_choice is None: - raise ValueError("Multi choice must be set for field type choice.") - else: - if field.choices is not None: - raise ValueError("Choices must not be set for field type other than choice.") - if field.multi_choice is not None: - raise ValueError("Multi choice must not be set for field type other than choice.") - - # Validate default value data type - if field.default_value is not None: - try: - validate_extra_field_value(field, field.default_value) - except ValueError as e: - raise ValueError(f"Default value is not valid: {e}") from None - - -def validate_extra_field_dict(all_fields: list[ExtraField], fields_input: dict[str, str]) -> None: - """Validate a dict of extra fields.""" - all_field_lookup = {field.key: field for field in all_fields} - for key, value in fields_input.items(): - if key not in all_field_lookup: - raise ValueError(f"Unknown extra field {key}.") - field = all_field_lookup[key] - try: - validate_extra_field_value(field, value) - except ValueError as e: - raise ValueError(f"Invalid extra field for key {key}: {e!s}") from None - - -extra_field_cache = {} - - -async def get_extra_fields(db: AsyncSession, entity_type: EntityType) -> list[ExtraField]: - """Get all extra fields for a specific entity type.""" - if entity_type in extra_field_cache: - return extra_field_cache[entity_type] - - setting_def = parse_setting(f"extra_fields_{entity_type.name}") - try: - setting = await db_setting.get(db, setting_def) - setting_value = setting.value - except ItemNotFoundError: - setting_value = setting_def.default - - setting_array = json.loads(setting_value) - if not isinstance(setting_array, list): - logger.warning("Setting %s is not a list, using default.", setting_def.key) - setting_array = [] - - fields = [ExtraField.parse_obj(obj) for obj in setting_array] - extra_field_cache[entity_type] = fields - return fields - - -async def add_or_update_extra_field(db: AsyncSession, entity_type: EntityType, extra_field: ExtraField) -> None: - """Add or update an extra field for a specific entity type.""" - validate_extra_field(extra_field) - - extra_fields = await get_extra_fields(db, entity_type) - - # If the field already exists, verify that we don't do anything that would break existing data - existing_field = next((field for field in extra_fields if field.key == extra_field.key), None) - if existing_field is not None: - if existing_field.field_type != extra_field.field_type: - raise ValueError("Field type cannot be changed.") - if extra_field.field_type == ExtraFieldType.choice: - # Can't change multi choice since that would break existing data - if existing_field.multi_choice != extra_field.multi_choice: - raise ValueError("Multi choice cannot be changed.") - - # Verify that we have only added new choices, not removed any - if ( - existing_field.choices is not None - and extra_field.choices is not None - and not all(choice in extra_field.choices for choice in existing_field.choices) - ): - raise ValueError("Cannot remove existing choices.") - - extra_fields = [field for field in extra_fields if field.key != extra_field.key] - extra_fields.append(extra_field) - - setting_def = parse_setting(f"extra_fields_{entity_type.name}") - await db_setting.update(db=db, definition=setting_def, value=json.dumps(jsonable_encoder(extra_fields))) - - # Update cache - extra_field_cache[entity_type] = extra_fields - - logger.info("Added/updated extra field %s for entity type %s.", extra_field.key, entity_type.name) +__all__ = [ + "EntityType", + "ExtraField", + "ExtraFieldParameters", + "ExtraFieldType", + "add_or_update_extra_field", + "delete_extra_field", + "extra_field_cache", + "get_extra_fields", + "populate_with_defaults", + "validate_extra_field", + "validate_extra_field_dict", + "validate_extra_field_value", +] async def delete_extra_field(db: AsyncSession, entity_type: EntityType, key: str) -> None: diff --git a/tests_integration/tests/fields/test_filter_sort.py b/tests_integration/tests/fields/test_filter_sort.py index 80d6cc605..5fefa139a 100644 --- a/tests_integration/tests/fields/test_filter_sort.py +++ b/tests_integration/tests/fields/test_filter_sort.py @@ -1,16 +1,17 @@ """Tests for filtering and sorting by custom fields.""" -import httpx import json -import pytest from typing import Any -from ..conftest import URL, assert_httpx_success, assert_lists_compatible +import httpx +import pytest + +from ..conftest import URL, assert_httpx_success @pytest.mark.asyncio async def test_filter_by_custom_field(random_filament: dict[str, Any]): - """Add a custom text field""" + """Add a custom text field.""" result = httpx.post( f"{URL}/api/v1/field/spool/test_field", json={ @@ -25,12 +26,7 @@ async def test_filter_by_custom_field(random_filament: dict[str, Any]): # Create a spool with a custom field result = httpx.post( f"{URL}/api/v1/spool", - json={ - "filament_id": random_filament["id"], - "extra": { - "test_field": json.dumps("test_value") - } - }, + json={"filament_id": random_filament["id"], "extra": {"test_field": json.dumps("test_value")}}, ) assert_httpx_success(result) spool_id1 = result.json()["id"] @@ -38,12 +34,7 @@ async def test_filter_by_custom_field(random_filament: dict[str, Any]): # Create another spool with a different custom field value result = httpx.post( f"{URL}/api/v1/spool", - json={ - "filament_id": random_filament["id"], - "extra": { - "test_field": json.dumps("other_value") - } - }, + json={"filament_id": random_filament["id"], "extra": {"test_field": json.dumps("other_value")}}, ) assert_httpx_success(result) spool_id2 = result.json()["id"] @@ -78,7 +69,7 @@ async def test_filter_by_custom_field(random_filament: dict[str, Any]): @pytest.mark.asyncio async def test_sort_by_custom_field(random_filament: dict[str, Any]): - """Add a custom text field""" + """Add a custom text field.""" result = httpx.post( f"{URL}/api/v1/field/spool/text_field", json={ @@ -93,24 +84,14 @@ async def test_sort_by_custom_field(random_filament: dict[str, Any]): # Text field result = httpx.post( f"{URL}/api/v1/spool", - json={ - "filament_id": random_filament["id"], - "extra": { - "text_field": json.dumps("B value") - } - }, + json={"filament_id": random_filament["id"], "extra": {"text_field": json.dumps("B value")}}, ) assert_httpx_success(result) spool_id1 = result.json()["id"] result = httpx.post( f"{URL}/api/v1/spool", - json={ - "filament_id": random_filament["id"], - "extra": { - "text_field": json.dumps("A value") - } - }, + json={"filament_id": random_filament["id"], "extra": {"text_field": json.dumps("A value")}}, ) assert_httpx_success(result) spool_id2 = result.json()["id"] @@ -146,7 +127,7 @@ async def test_sort_by_custom_field(random_filament: dict[str, Any]): @pytest.mark.asyncio async def test_filter_by_numeric_custom_field(random_filament: dict[str, Any]): - """Add a custom numeric field""" + """Add a custom numeric field.""" result = httpx.post( f"{URL}/api/v1/field/spool/numeric_field", json={ @@ -160,12 +141,7 @@ async def test_filter_by_numeric_custom_field(random_filament: dict[str, Any]): # Create a spool with a numeric custom field result = httpx.post( f"{URL}/api/v1/spool", - json={ - "filament_id": random_filament["id"], - "extra": { - "numeric_field": json.dumps(100) - } - }, + json={"filament_id": random_filament["id"], "extra": {"numeric_field": json.dumps(100)}}, ) assert_httpx_success(result) spool_id1 = result.json()["id"] @@ -173,12 +149,7 @@ async def test_filter_by_numeric_custom_field(random_filament: dict[str, Any]): # Create another spool with a different numeric value result = httpx.post( f"{URL}/api/v1/spool", - json={ - "filament_id": random_filament["id"], - "extra": { - "numeric_field": json.dumps(200) - } - }, + json={"filament_id": random_filament["id"], "extra": {"numeric_field": json.dumps(200)}}, ) assert_httpx_success(result) spool_id2 = result.json()["id"] @@ -209,7 +180,7 @@ async def test_filter_by_numeric_custom_field(random_filament: dict[str, Any]): @pytest.mark.asyncio async def test_filter_by_boolean_custom_field(random_filament: dict[str, Any]): - """Add a custom boolean field""" + """Add a custom boolean field.""" result = httpx.post( f"{URL}/api/v1/field/spool/boolean_field", json={ @@ -223,12 +194,7 @@ async def test_filter_by_boolean_custom_field(random_filament: dict[str, Any]): # Create a spool with a boolean custom field result = httpx.post( f"{URL}/api/v1/spool", - json={ - "filament_id": random_filament["id"], - "extra": { - "boolean_field": json.dumps(True) - } - }, + json={"filament_id": random_filament["id"], "extra": {"boolean_field": json.dumps(bool(1))}}, ) assert_httpx_success(result) spool_id1 = result.json()["id"] @@ -236,12 +202,7 @@ async def test_filter_by_boolean_custom_field(random_filament: dict[str, Any]): # Create another spool with a different boolean value result = httpx.post( f"{URL}/api/v1/spool", - json={ - "filament_id": random_filament["id"], - "extra": { - "boolean_field": json.dumps(False) - } - }, + json={"filament_id": random_filament["id"], "extra": {"boolean_field": json.dumps(bool(0))}}, ) assert_httpx_success(result) spool_id2 = result.json()["id"] @@ -419,7 +380,7 @@ async def test_filter_multi_choice_custom_field(random_filament: dict[str, Any]) @pytest.mark.asyncio async def test_filter_empty_custom_field(random_filament: dict[str, Any]): - """Test the filter returns items that have no value set for a custom field.""" + """Test the empty-string filter returns items that have no value set for a custom field.""" result = httpx.post( f"{URL}/api/v1/field/spool/optional_field", json={"name": "Optional field", "field_type": "text"}, @@ -442,8 +403,8 @@ async def test_filter_empty_custom_field(random_filament: dict[str, Any]): assert_httpx_success(result) spool_id2 = result.json()["id"] - # Filter by — spool 2 (no field row) should appear, spool 1 should not - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.optional_field": ""}) + # Filter by empty string — spool 2 (no field row) should appear, spool 1 should not + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.optional_field": ""}) assert_httpx_success(result) ids = {item["id"] for item in result.json()} assert spool_id2 in ids @@ -462,6 +423,44 @@ async def test_filter_empty_custom_field(random_filament: dict[str, Any]): httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() +@pytest.mark.asyncio +async def test_invalid_numeric_custom_field_filters_return_400(): + """Invalid numeric custom-field filters should fail explicitly instead of being ignored.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/numeric_field", + json={ + "name": "Numeric field", + "field_type": "integer", + }, + ) + assert_httpx_success(result) + + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.numeric_field": "abc"}) + assert result.status_code == 400 + assert "Invalid integer filter value" in result.json()["message"] + + httpx.delete(f"{URL}/api/v1/field/spool/numeric_field").raise_for_status() + + +@pytest.mark.asyncio +async def test_invalid_boolean_custom_field_filters_return_400(): + """Invalid boolean custom-field filters should fail explicitly instead of being coerced.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/boolean_field", + json={ + "name": "Boolean field", + "field_type": "boolean", + }, + ) + assert_httpx_success(result) + + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.boolean_field": "maybe"}) + assert result.status_code == 400 + assert "Invalid boolean filter value" in result.json()["message"] + + httpx.delete(f"{URL}/api/v1/field/spool/boolean_field").raise_for_status() + + @pytest.mark.asyncio async def test_filter_sort_filament_custom_field(random_filament: dict[str, Any]): """Test filtering and sorting filaments by a custom field.""" @@ -482,7 +481,12 @@ async def test_filter_sort_filament_custom_field(random_filament: dict[str, Any] result = httpx.post( f"{URL}/api/v1/filament", - json={"vendor_id": vendor_id, "density": 1.24, "diameter": 1.75, "extra": {"filament_tag": json.dumps("alpha")}}, + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"filament_tag": json.dumps("alpha")}, + }, ) assert_httpx_success(result) filament_id2 = result.json()["id"] From be2569d3ac08c4ec952cfb1db719e36c81cb2ad9 Mon Sep 17 00:00:00 2001 From: akira69 Date: Thu, 26 Mar 2026 00:03:58 -0500 Subject: [PATCH 06/16] fix: clean up custom field table state handling --- client/src/components/column.tsx | 39 +++++++++++------------ client/src/components/dataProvider.ts | 13 ++++---- client/src/utils/filtering.ts | 46 +++++++++++++++------------ client/src/utils/sorting.ts | 19 +++++------ 4 files changed, 58 insertions(+), 59 deletions(-) diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index 7f5103048..d5a5f7658 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -40,7 +40,7 @@ export interface Action { interface BaseColumnProps { id: string | string[]; - dataId?: keyof Obj & string | string; // Allow string values for custom fields + dataId?: (keyof Obj & string) | string; // Allow string values for custom fields i18ncat?: string; i18nkey?: string; title?: string; @@ -98,10 +98,8 @@ function Column( // Sorting if (props.sorter) { columnProps.sorter = true; - columnProps.sortOrder = getSortOrderForField( - typeSorters(props.tableState.sorters), - props.dataId ?? (props.id as keyof Obj), - ); + const sortField = props.dataId ?? (Array.isArray(props.id) ? props.id.join(".") : props.id); + columnProps.sortOrder = getSortOrderForField(typeSorters(props.tableState.sorters), sortField); } // Filter @@ -207,11 +205,12 @@ export function FilteredQueryColumn(props: FilteredQueryColu } filters.push({ text: "", - value: "", + value: "", }); const typedFilters = typeFilters(props.tableState.filters); - const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj)); + const filterField = props.dataId ?? (Array.isArray(props.id) ? props.id.join(".") : props.id); + const filteredValue = getFiltersForField(typedFilters, filterField); const onFilterDropdownOpen = () => { query.refetch(); @@ -325,7 +324,8 @@ export function SpoolIconColumn(props: SpoolIconColumnProps< }); const typedFilters = typeFilters(props.tableState.filters); - const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj)); + const filterField = props.dataId ?? (Array.isArray(props.id) ? props.id.join(".") : props.id); + const filteredValue = getFiltersForField(typedFilters, filterField); const onFilterDropdownOpen = () => { query.refetch(); @@ -392,45 +392,42 @@ export function NumberRangeColumn(props: NumberColumnProps { + field.choices.forEach((choice) => { filters.push({ text: choice, value: `"${choice}"`, // Exact match }); }); } - + // For boolean fields, add true/false options if (field.field_type === FieldType.boolean) { - filters.push( - { text: "Yes", value: "true" }, - { text: "No", value: "false" } - ); + filters.push({ text: "Yes", value: "true" }, { text: "No", value: "false" }); } - + // Add empty option for all field types filters.push({ text: "", - value: "", + value: "", }); - + return filters; } export function CustomFieldColumn(props: Omit, "id"> & { field: Field }) { const field = props.field; const fieldId = `extra.${field.key}`; - + // Get filtered values for this field const typedFilters = typeFilters(props.tableState.filters); const filteredValue = getFiltersForField(typedFilters, fieldId); - + // Create filters based on field type const filters = createCustomFieldFilters(field); - + const commonProps = { ...props, id: ["extra", field.key], diff --git a/client/src/components/dataProvider.ts b/client/src/components/dataProvider.ts index d2c439af5..7bfa36978 100644 --- a/client/src/components/dataProvider.ts +++ b/client/src/components/dataProvider.ts @@ -4,7 +4,6 @@ import { AxiosInstance } from "axios"; import { stringify } from "query-string"; import { getCustomFieldFilters } from "../utils/filtering"; import { isCustomField } from "../utils/queryFields"; -import { getCustomFieldSorters, isCustomFieldSorter } from "../utils/sorting"; type MethodTypes = "get" | "delete" | "head" | "options"; type MethodTypesWithBody = "post" | "put" | "patch"; @@ -44,14 +43,14 @@ const dataProvider = ( if (!("field" in filter)) { throw Error("Filter must be a LogicalFilter."); } - + const field = filter.field; - + // Skip custom fields, they'll be handled separately - if (typeof field === 'string' && isCustomField(field)) { + if (typeof field === "string" && isCustomField(field)) { return; } - + if (filter.value.length > 0) { const filterValueArray = Array.isArray(filter.value) ? filter.value : [filter.value]; @@ -67,12 +66,12 @@ const dataProvider = ( queryParams[field] = filterValue; } }); - + // Process custom field filters const customFieldFilters = getCustomFieldFilters(filters); Object.entries(customFieldFilters).forEach(([key, values]) => { if (values.length > 0) { - queryParams[`extra.${key}`] = values.join(","); + queryParams[`extra.${key}`] = values.map((value) => (value === "" ? "" : value)).join(","); } }); } diff --git a/client/src/utils/filtering.ts b/client/src/utils/filtering.ts index 177ab96a7..6acf9a8b3 100644 --- a/client/src/utils/filtering.ts +++ b/client/src/utils/filtering.ts @@ -17,10 +17,7 @@ export function typeFilters(filters: CrudFilter[]): TypedCrudFilter[] * @param field The field to get the filter values for. * @returns An array of filter values for the given field. */ -export function getFiltersForField( - filters: TypedCrudFilter[], - field: Field | string, -): string[] { +export function getFiltersForField(filters: TypedCrudFilter[], field: Field | string): string[] { const filterValues: string[] = []; filters.forEach((filter) => { if (filter.field === field) { @@ -36,7 +33,16 @@ export function getFiltersForField( * @param value The value to filter by * @returns The formatted filter value */ -export function formatCustomFieldFilterValue(field: Field, value: any): string { +type CustomFieldFilterValue = + | string + | number + | boolean + | Date + | [number | null | undefined, number | null | undefined] + | null + | undefined; + +export function formatCustomFieldFilterValue(field: Field, value: CustomFieldFilterValue): string { switch (field.field_type) { case FieldType.text: case FieldType.choice: @@ -48,34 +54,34 @@ export function formatCustomFieldFilterValue(field: Field, value: any): string { return `"${value}"`; } } - return value; - + return value == null ? "" : String(value); + case FieldType.integer: case FieldType.float: // For numeric fields, we can use the value directly - return value.toString(); - + return value == null ? "" : value.toString(); + case FieldType.boolean: // For boolean fields, convert to "true" or "false" return value ? "true" : "false"; - + case FieldType.datetime: // For datetime fields, format as ISO string if (value instanceof Date) { return value.toISOString(); } - return value; - + return value == null ? "" : String(value); + case FieldType.integer_range: case FieldType.float_range: // For range fields, format as min:max if (Array.isArray(value) && value.length === 2) { return `${value[0] ?? ""}:${value[1] ?? ""}`; } - return value; - + return value == null ? "" : String(value); + default: - return value; + return value == null ? "" : String(value); } } @@ -84,23 +90,23 @@ export function formatCustomFieldFilterValue(field: Field, value: any): string { * @param filters The list of filters * @returns An object with custom field keys and their filter values */ -export function getCustomFieldFilters( - filters: CrudFilter[] | TypedCrudFilter[] +export function getCustomFieldFilters( + filters: CrudFilter[] | TypedCrudFilter[], ): Record { const customFieldFilters: Record = {}; - + filters.forEach((filter) => { if (!("field" in filter)) { return; // Skip non-field filters } - + const field = filter.field.toString(); if (isCustomField(field)) { const key = getCustomFieldKey(field); customFieldFilters[key] = filter.value as string[]; } }); - + return customFieldFilters; } diff --git a/client/src/utils/sorting.ts b/client/src/utils/sorting.ts index c75603d2a..27ee36b3d 100644 --- a/client/src/utils/sorting.ts +++ b/client/src/utils/sorting.ts @@ -1,6 +1,6 @@ import { CrudSort } from "@refinedev/core"; import { SortOrder } from "antd/es/table/interface"; -import { getCustomFieldKey, isCustomField } from "./queryFields"; +import { Field, getCustomFieldKey, isCustomField } from "./queryFields"; interface TypedCrudSort { field: keyof Obj | string; @@ -13,10 +13,7 @@ interface TypedCrudSort { * @param field The field to get the sort order for. * @returns The sort order for the given field, or undefined if the field is not being sorted. */ -export function getSortOrderForField( - sorters: TypedCrudSort[], - field: Field | string, -): SortOrder | undefined { +export function getSortOrderForField(sorters: TypedCrudSort[], field: Field | string): SortOrder | undefined { const sorter = sorters.find((s) => s.field === field); if (sorter) { return sorter.order === "asc" ? "ascend" : "descend"; @@ -33,8 +30,8 @@ export function typeSorters(sorters: CrudSort[]): TypedCrudSort[] { * @param sorter The sorter to check * @returns True if the sorter is for a custom field */ -export function isCustomFieldSorter(sorter: TypedCrudSort | CrudSort): boolean { - return typeof sorter.field === 'string' && isCustomField(sorter.field); +export function isCustomFieldSorter(sorter: TypedCrudSort | CrudSort): boolean { + return typeof sorter.field === "string" && isCustomField(sorter.field); } /** @@ -42,11 +39,11 @@ export function isCustomFieldSorter(sorter: TypedCrudSort | Crud * @param sorters The list of sorters * @returns An object with custom field keys and their sort orders */ -export function getCustomFieldSorters( - sorters: TypedCrudSort[] | CrudSort[] +export function getCustomFieldSorters( + sorters: TypedCrudSort[] | CrudSort[], ): Record { const customFieldSorters: Record = {}; - + sorters.forEach((sorter) => { if (isCustomFieldSorter(sorter)) { const field = sorter.field.toString(); @@ -54,6 +51,6 @@ export function getCustomFieldSorters( customFieldSorters[key] = sorter.order; } }); - + return customFieldSorters; } From 62c1488ae5c254e6c227589367b1a9203d0d3da6 Mon Sep 17 00:00:00 2001 From: akira69 Date: Thu, 26 Mar 2026 00:12:38 -0500 Subject: [PATCH 07/16] docs: update extra field table view description --- client/public/locales/en/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 88ff2ae85..049923006 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -326,7 +326,7 @@ }, "extra_fields": { "tab": "Extra Fields", - "description": "

Here you can add extra custom fields to your entities.

Once a field is added, you can not change its key or type, and for choice type fields you can not remove choices or change the multi choice state. If you remove a field, the associated data for all entities will be deleted.

The key is what other programs read/write the data as, so if your custom field is supposed to integrate with a third-party program, make sure to set it correctly. Default value is only applied to new items.

Extra fields can not be sorted or filtered in the table views.

", + "description": "

Here you can add extra custom fields to your entities.

Once a field is added, you can not change its key or type, and for choice type fields you can not remove choices or change the multi choice state. If you remove a field, the associated data for all entities will be deleted.

The key is what other programs read/write the data as, so if your custom field is supposed to integrate with a third-party program, make sure to set it correctly. Default value is only applied to new items.

Extra fields can be shown and sorted in the table views. Choice and boolean fields expose filter options there as well, and all extra field types support filtering for empty values.

", "params": { "key": "Key", "name": "Name", From 38435e9716c5b20f9b5848f8c4944c2d7247c27c Mon Sep 17 00:00:00 2001 From: akira69 Date: Thu, 26 Mar 2026 00:17:40 -0500 Subject: [PATCH 08/16] fix: narrow boolean custom field filter syntax --- spoolman/database/extra_field_query.py | 6 +++--- tests_integration/tests/fields/test_filter_sort.py | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/spoolman/database/extra_field_query.py b/spoolman/database/extra_field_query.py index 547efeeb6..43e658cb9 100644 --- a/spoolman/database/extra_field_query.py +++ b/spoolman/database/extra_field_query.py @@ -40,11 +40,11 @@ def _get_entity_id_column(field_table: type[models.Base]) -> InstrumentedAttribu def _parse_boolean_filter(value: str) -> bool: - """Parse a boolean filter using the same loose-but-explicit semantics as the API.""" + """Parse a boolean filter using explicit true/false tokens only.""" normalized = value.strip().lower() - if normalized in {"true", "1", "yes"}: + if normalized == "true": return True - if normalized in {"false", "0", "no"}: + if normalized == "false": return False raise ValueError(f"Invalid boolean filter value: {value}") diff --git a/tests_integration/tests/fields/test_filter_sort.py b/tests_integration/tests/fields/test_filter_sort.py index 5fefa139a..f2b47e5f9 100644 --- a/tests_integration/tests/fields/test_filter_sort.py +++ b/tests_integration/tests/fields/test_filter_sort.py @@ -458,6 +458,10 @@ async def test_invalid_boolean_custom_field_filters_return_400(): assert result.status_code == 400 assert "Invalid boolean filter value" in result.json()["message"] + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.boolean_field": "yes"}) + assert result.status_code == 400 + assert "Invalid boolean filter value" in result.json()["message"] + httpx.delete(f"{URL}/api/v1/field/spool/boolean_field").raise_for_status() From 882aa3acd9177266a8a9ec6d21ffb832a6f370d0 Mon Sep 17 00:00:00 2001 From: Dieter Blomme Date: Sun, 29 Mar 2026 03:57:10 +0200 Subject: [PATCH 09/16] Expand test coverage for all custom field types/entities and fix range filter/sort - Add comprehensive tests covering all 9 field types (text, integer, float, boolean, single-choice, multi-choice, datetime, integer_range, float_range) for filter and sort on spool, filament, and vendor entities - Add invalid-filter 400 tests for float, integer_range, and float_range - Fix integer_range/float_range filter to use LIKE pattern matching against Python's deterministic json.dumps output instead of fragile .contains() - Add integer_range/float_range sort support via a @compiles helper (_JsonArrayFirstElement) that emits CAST(col AS JSON)->>0 on PostgreSQL and JSON_EXTRACT(col, '$[0]') on SQLite/MariaDB - Add logger and __all__ to extra_fields.py (aligns with PR #893) - All tests verified passing on postgres, sqlite, and mariadb --- spoolman/database/extra_field_query.py | 34 +- spoolman/extra_fields.py | 5 +- .../tests/fields/test_filter_sort.py | 1753 ++++++++++++++--- 3 files changed, 1556 insertions(+), 236 deletions(-) diff --git a/spoolman/database/extra_field_query.py b/spoolman/database/extra_field_query.py index 43e658cb9..8c238725a 100644 --- a/spoolman/database/extra_field_query.py +++ b/spoolman/database/extra_field_query.py @@ -7,6 +7,8 @@ import sqlalchemy from sqlalchemy import Select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.sql.expression import FunctionElement from spoolman.database import models from spoolman.database.utils import SortOrder @@ -17,6 +19,29 @@ from sqlalchemy.orm.attributes import InstrumentedAttribute +class _JsonArrayFirstElement(FunctionElement): + """Cross-database helper: return the first element of a JSON array stored as text.""" + + name = "json_array_first_element" + inherit_cache = True + + +@compiles(_JsonArrayFirstElement, "postgresql") +def _compile_json_array_first_pg(element: _JsonArrayFirstElement, compiler: object, **kw: object) -> str: # type: ignore[misc] + """PostgreSQL: CAST(value AS JSON)->>'0' returns the first element as TEXT.""" + (col_expr,) = element.clauses + col_sql = compiler.process(col_expr, **kw) # type: ignore[union-attr] + return f"(CAST({col_sql} AS JSON)->>0)" + + +@compiles(_JsonArrayFirstElement) +def _compile_json_array_first_default(element: _JsonArrayFirstElement, compiler: object, **kw: object) -> str: # type: ignore[misc] + """SQLite/MariaDB: json_extract(value, '$[0]') returns the first element as a scalar.""" + (col_expr,) = element.clauses + col_sql = compiler.process(col_expr, **kw) # type: ignore[union-attr] + return f"JSON_EXTRACT({col_sql}, '$[0]')" + + def _get_field_table_for_entity(entity_type: EntityType) -> type[models.Base]: """Map an entity type to its extra-field table.""" if entity_type == EntityType.spool: @@ -46,7 +71,7 @@ def _parse_boolean_filter(value: str) -> bool: return True if normalized == "false": return False - raise ValueError(f"Invalid boolean filter value: {value}") + raise ValueError(f"Invalid boolean filter value: {value!r}") async def apply_extra_field_filters_and_sort( @@ -234,10 +259,9 @@ def add_order_by_extra_field( elif field_type == ExtraFieldType.float: sort_expr = sqlalchemy.cast(value_subq, sqlalchemy.Float) elif field_type in (ExtraFieldType.integer_range, ExtraFieldType.float_range): - sort_expr = sqlalchemy.cast( - value_subq[0], - sqlalchemy.Integer if field_type == ExtraFieldType.integer_range else sqlalchemy.Float, - ) + cast_type = sqlalchemy.Integer if field_type == ExtraFieldType.integer_range else sqlalchemy.Float + # Use dialect-specific JSON first-element extraction, then cast to numeric. + sort_expr = sqlalchemy.cast(_JsonArrayFirstElement(value_subq), cast_type) else: sort_expr = value_subq diff --git a/spoolman/extra_fields.py b/spoolman/extra_fields.py index 958acc900..6b3ad0f6b 100644 --- a/spoolman/extra_fields.py +++ b/spoolman/extra_fields.py @@ -59,7 +59,8 @@ async def delete_extra_field(db: AsyncSession, entity_type: EntityType, key: str # Update cache extra_field_cache[entity_type] = extra_fields - # Delete the extra field for all entities + logger.info("Deleted extra field %r for entity type %r.", key, entity_type.name) + if entity_type == EntityType.vendor: await db_vendor.clear_extra_field(db, key) elif entity_type == EntityType.filament: @@ -69,8 +70,6 @@ async def delete_extra_field(db: AsyncSession, entity_type: EntityType, key: str else: raise ValueError(f"Unknown entity type {entity_type.name}.") - logger.info("Deleted extra field %s for entity type %s.", key, entity_type.name) - async def populate_with_defaults(db: AsyncSession, entity_type: EntityType, existing: dict[str, str]) -> None: """Populate the given list of extra fields with defaults.""" diff --git a/tests_integration/tests/fields/test_filter_sort.py b/tests_integration/tests/fields/test_filter_sort.py index f2b47e5f9..b9d1aa159 100644 --- a/tests_integration/tests/fields/test_filter_sort.py +++ b/tests_integration/tests/fields/test_filter_sort.py @@ -8,6 +8,10 @@ from ..conftest import URL, assert_httpx_success +# --------------------------------------------------------------------------- +# Spool - text +# --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_filter_by_custom_field(random_filament: dict[str, Any]): @@ -22,8 +26,6 @@ async def test_filter_by_custom_field(random_filament: dict[str, Any]): ) assert_httpx_success(result) - """Test filtering by custom field.""" - # Create a spool with a custom field result = httpx.post( f"{URL}/api/v1/spool", json={"filament_id": random_filament["id"], "extra": {"test_field": json.dumps("test_value")}}, @@ -31,7 +33,6 @@ async def test_filter_by_custom_field(random_filament: dict[str, Any]): assert_httpx_success(result) spool_id1 = result.json()["id"] - # Create another spool with a different custom field value result = httpx.post( f"{URL}/api/v1/spool", json={"filament_id": random_filament["id"], "extra": {"test_field": json.dumps("other_value")}}, @@ -39,30 +40,28 @@ async def test_filter_by_custom_field(random_filament: dict[str, Any]): assert_httpx_success(result) spool_id2 = result.json()["id"] - # Filter by custom field + # Substring filter result = httpx.get(f"{URL}/api/v1/spool", params={"extra.test_field": "test_value"}) assert_httpx_success(result) data = result.json() assert len(data) == 1 assert data[0]["id"] == spool_id1 - # Filter by custom field with exact match + # Exact-match filter (wrapped in double quotes) result = httpx.get(f"{URL}/api/v1/spool", params={"extra.test_field": '"test_value"'}) assert_httpx_success(result) data = result.json() assert len(data) == 1 assert data[0]["id"] == spool_id1 - # Filter by custom field with multiple values + # Multi-value OR filter result = httpx.get(f"{URL}/api/v1/spool", params={"extra.test_field": "test_value,other_value"}) assert_httpx_success(result) data = result.json() assert len(data) == 2 assert {item["id"] for item in data} == {spool_id1, spool_id2} - # Clean up - result = httpx.delete(f"{URL}/api/v1/field/spool/test_field") - assert_httpx_success(result) + httpx.delete(f"{URL}/api/v1/field/spool/test_field").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() @@ -72,16 +71,10 @@ async def test_sort_by_custom_field(random_filament: dict[str, Any]): """Add a custom text field.""" result = httpx.post( f"{URL}/api/v1/field/spool/text_field", - json={ - "name": "Text field", - "field_type": "text", - }, + json={"name": "Text field", "field_type": "text"}, ) assert_httpx_success(result) - """Test sorting by custom field.""" - # Create spools with custom fields of different types - # Text field result = httpx.post( f"{URL}/api/v1/spool", json={"filament_id": random_filament["id"], "extra": {"text_field": json.dumps("B value")}}, @@ -96,49 +89,39 @@ async def test_sort_by_custom_field(random_filament: dict[str, Any]): assert_httpx_success(result) spool_id2 = result.json()["id"] - # Sort by custom field ascending result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.text_field:asc"}) assert_httpx_success(result) - data = result.json() - assert len(data) >= 2 - # Find our test spools in the results - test_spools = [item for item in data if item["id"] in (spool_id1, spool_id2)] + test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id2 # A value should come first - assert test_spools[1]["id"] == spool_id1 # B value should come second + assert test_spools[0]["id"] == spool_id2 # "A value" first + assert test_spools[1]["id"] == spool_id1 # "B value" second - # Sort by custom field descending result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.text_field:desc"}) assert_httpx_success(result) - data = result.json() - assert len(data) >= 2 - # Find our test spools in the results - test_spools = [item for item in data if item["id"] in (spool_id1, spool_id2)] + test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id1 # B value should come first - assert test_spools[1]["id"] == spool_id2 # A value should come second + assert test_spools[0]["id"] == spool_id1 # "B value" first + assert test_spools[1]["id"] == spool_id2 # "A value" second - # Clean up - result = httpx.delete(f"{URL}/api/v1/field/spool/text_field") - assert_httpx_success(result) + httpx.delete(f"{URL}/api/v1/field/spool/text_field").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() +# --------------------------------------------------------------------------- +# Spool - integer +# --------------------------------------------------------------------------- + + @pytest.mark.asyncio async def test_filter_by_numeric_custom_field(random_filament: dict[str, Any]): - """Add a custom numeric field.""" + """Test filtering and sorting by a custom integer field.""" result = httpx.post( f"{URL}/api/v1/field/spool/numeric_field", - json={ - "name": "Numeric field", - "field_type": "integer", - }, + json={"name": "Numeric field", "field_type": "integer"}, ) assert_httpx_success(result) - """Test filtering by numeric custom field.""" - # Create a spool with a numeric custom field result = httpx.post( f"{URL}/api/v1/spool", json={"filament_id": random_filament["id"], "extra": {"numeric_field": json.dumps(100)}}, @@ -146,7 +129,6 @@ async def test_filter_by_numeric_custom_field(random_filament: dict[str, Any]): assert_httpx_success(result) spool_id1 = result.json()["id"] - # Create another spool with a different numeric value result = httpx.post( f"{URL}/api/v1/spool", json={"filament_id": random_filament["id"], "extra": {"numeric_field": json.dumps(200)}}, @@ -154,71 +136,34 @@ async def test_filter_by_numeric_custom_field(random_filament: dict[str, Any]): assert_httpx_success(result) spool_id2 = result.json()["id"] - # Filter by numeric custom field result = httpx.get(f"{URL}/api/v1/spool", params={"extra.numeric_field": "100"}) assert_httpx_success(result) data = result.json() assert len(data) == 1 assert data[0]["id"] == spool_id1 - # Sort by numeric custom field ascending result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.numeric_field:asc"}) assert_httpx_success(result) - data = result.json() - # Find our test spools in the results - test_spools = [item for item in data if item["id"] in (spool_id1, spool_id2)] + test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id1 # 100 should come first - assert test_spools[1]["id"] == spool_id2 # 200 should come second + assert test_spools[0]["id"] == spool_id1 # 100 first + assert test_spools[1]["id"] == spool_id2 # 200 second - # Clean up - result = httpx.delete(f"{URL}/api/v1/field/spool/numeric_field") + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.numeric_field:desc"}) assert_httpx_success(result) + test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] + assert len(test_spools) == 2 + assert test_spools[0]["id"] == spool_id2 # 200 first + assert test_spools[1]["id"] == spool_id1 # 100 second + + httpx.delete(f"{URL}/api/v1/field/spool/numeric_field").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() -@pytest.mark.asyncio -async def test_filter_by_boolean_custom_field(random_filament: dict[str, Any]): - """Add a custom boolean field.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/boolean_field", - json={ - "name": "Boolean field", - "field_type": "boolean", - }, - ) - assert_httpx_success(result) - - """Test filtering by boolean custom field.""" - # Create a spool with a boolean custom field - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"boolean_field": json.dumps(bool(1))}}, - ) - assert_httpx_success(result) - spool_id1 = result.json()["id"] - - # Create another spool with a different boolean value - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"boolean_field": json.dumps(bool(0))}}, - ) - assert_httpx_success(result) - spool_id2 = result.json()["id"] - - # Filter by boolean custom field - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.boolean_field": "true"}) - assert_httpx_success(result) - data = result.json() - assert len(data) == 1 - assert data[0]["id"] == spool_id1 - - # Clean up - result = httpx.delete(f"{URL}/api/v1/field/spool/boolean_field") - assert_httpx_success(result) - httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() +# --------------------------------------------------------------------------- +# Spool - float +# --------------------------------------------------------------------------- @pytest.mark.asyncio @@ -244,14 +189,12 @@ async def test_filter_and_sort_float_custom_field(random_filament: dict[str, Any assert_httpx_success(result) spool_id2 = result.json()["id"] - # Filter by exact float value result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_field": "1.5"}) assert_httpx_success(result) ids = {item["id"] for item in result.json()} assert spool_id1 in ids assert spool_id2 not in ids - # Sort ascending: 1.5 before 2.5 result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.float_field:asc"}) assert_httpx_success(result) test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] @@ -259,7 +202,6 @@ async def test_filter_and_sort_float_custom_field(random_filament: dict[str, Any assert test_spools[0]["id"] == spool_id1 assert test_spools[1]["id"] == spool_id2 - # Sort descending: 2.5 before 1.5 result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.float_field:desc"}) assert_httpx_success(result) test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] @@ -267,15 +209,82 @@ async def test_filter_and_sort_float_custom_field(random_filament: dict[str, Any assert test_spools[0]["id"] == spool_id2 assert test_spools[1]["id"] == spool_id1 - # Clean up httpx.delete(f"{URL}/api/v1/field/spool/float_field").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() +# --------------------------------------------------------------------------- +# Spool - boolean +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_by_boolean_custom_field(random_filament: dict[str, Any]): + """Test filtering and sorting by a custom boolean field.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/boolean_field", + json={"name": "Boolean field", "field_type": "boolean"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"], "extra": {"boolean_field": json.dumps(bool(1))}}, + ) + assert_httpx_success(result) + spool_id_true = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"], "extra": {"boolean_field": json.dumps(bool(0))}}, + ) + assert_httpx_success(result) + spool_id_false = result.json()["id"] + + # Filter true + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.boolean_field": "true"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id_true in ids + assert spool_id_false not in ids + + # Filter false + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.boolean_field": "false"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id_false in ids + assert spool_id_true not in ids + + # Sort ascending: false before true + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.boolean_field:asc"}) + assert_httpx_success(result) + test_spools = [item for item in result.json() if item["id"] in (spool_id_true, spool_id_false)] + assert len(test_spools) == 2 + assert test_spools[0]["id"] == spool_id_false + assert test_spools[1]["id"] == spool_id_true + + # Sort descending: true before false + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.boolean_field:desc"}) + assert_httpx_success(result) + test_spools = [item for item in result.json() if item["id"] in (spool_id_true, spool_id_false)] + assert len(test_spools) == 2 + assert test_spools[0]["id"] == spool_id_true + assert test_spools[1]["id"] == spool_id_false + + httpx.delete(f"{URL}/api/v1/field/spool/boolean_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id_true}").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id_false}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Spool - single-choice +# --------------------------------------------------------------------------- + + @pytest.mark.asyncio async def test_filter_single_choice_custom_field(random_filament: dict[str, Any]): - """Test filtering by a single-choice custom field.""" + """Test filtering and sorting by a single-choice custom field.""" result = httpx.post( f"{URL}/api/v1/field/spool/choice_field", json={ @@ -301,26 +310,46 @@ async def test_filter_single_choice_custom_field(random_filament: dict[str, Any] assert_httpx_success(result) spool_id2 = result.json()["id"] - # Filter by a single choice value + # Filter by single value result = httpx.get(f"{URL}/api/v1/spool", params={"extra.choice_field": "OptionA"}) assert_httpx_success(result) ids = {item["id"] for item in result.json()} assert spool_id1 in ids assert spool_id2 not in ids - # Filter by multiple choices (OR) — both should be returned + # Multi-value OR result = httpx.get(f"{URL}/api/v1/spool", params={"extra.choice_field": "OptionA,OptionB"}) assert_httpx_success(result) ids = {item["id"] for item in result.json()} assert spool_id1 in ids assert spool_id2 in ids - # Clean up + # Sort ascending: OptionA before OptionB + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.choice_field:asc"}) + assert_httpx_success(result) + test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] + assert len(test_spools) == 2 + assert test_spools[0]["id"] == spool_id1 + assert test_spools[1]["id"] == spool_id2 + + # Sort descending + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.choice_field:desc"}) + assert_httpx_success(result) + test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] + assert len(test_spools) == 2 + assert test_spools[0]["id"] == spool_id2 + assert test_spools[1]["id"] == spool_id1 + httpx.delete(f"{URL}/api/v1/field/spool/choice_field").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() +# --------------------------------------------------------------------------- +# Spool - multi-choice +# --------------------------------------------------------------------------- + + @pytest.mark.asyncio async def test_filter_multi_choice_custom_field(random_filament: dict[str, Any]): """Test filtering by a multi-choice custom field.""" @@ -335,7 +364,6 @@ async def test_filter_multi_choice_custom_field(random_filament: dict[str, Any]) ) assert_httpx_success(result) - # Spool 1 has choices A and B result = httpx.post( f"{URL}/api/v1/spool", json={"filament_id": random_filament["id"], "extra": {"multi_choice_field": json.dumps(["A", "B"])}}, @@ -343,7 +371,6 @@ async def test_filter_multi_choice_custom_field(random_filament: dict[str, Any]) assert_httpx_success(result) spool_id1 = result.json()["id"] - # Spool 2 has only choice C result = httpx.post( f"{URL}/api/v1/spool", json={"filament_id": random_filament["id"], "extra": {"multi_choice_field": json.dumps(["C"])}}, @@ -351,226 +378,1496 @@ async def test_filter_multi_choice_custom_field(random_filament: dict[str, Any]) assert_httpx_success(result) spool_id2 = result.json()["id"] - # Filter by A — only spool 1 has A + # Filter by A - only spool 1 result = httpx.get(f"{URL}/api/v1/spool", params={"extra.multi_choice_field": "A"}) assert_httpx_success(result) ids = {item["id"] for item in result.json()} assert spool_id1 in ids assert spool_id2 not in ids - # Filter by C — only spool 2 has C + # Filter by C - only spool 2 result = httpx.get(f"{URL}/api/v1/spool", params={"extra.multi_choice_field": "C"}) assert_httpx_success(result) ids = {item["id"] for item in result.json()} assert spool_id2 in ids assert spool_id1 not in ids - # Filter by A,C (OR) — both should be returned + # Multi-value OR: both result = httpx.get(f"{URL}/api/v1/spool", params={"extra.multi_choice_field": "A,C"}) assert_httpx_success(result) ids = {item["id"] for item in result.json()} assert spool_id1 in ids assert spool_id2 in ids - # Clean up httpx.delete(f"{URL}/api/v1/field/spool/multi_choice_field").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() +# --------------------------------------------------------------------------- +# Spool - datetime +# --------------------------------------------------------------------------- + + @pytest.mark.asyncio -async def test_filter_empty_custom_field(random_filament: dict[str, Any]): - """Test the empty-string filter returns items that have no value set for a custom field.""" +async def test_filter_sort_datetime_spool(random_filament: dict[str, Any]): + """Test filtering and sorting by a custom datetime field on spools.""" result = httpx.post( - f"{URL}/api/v1/field/spool/optional_field", - json={"name": "Optional field", "field_type": "text"}, + f"{URL}/api/v1/field/spool/dt_field", + json={"name": "Datetime field", "field_type": "datetime"}, ) assert_httpx_success(result) - # Spool 1 has the field set + dt_early = "2023-01-01T00:00:00" + dt_late = "2024-06-15T12:30:00" + result = httpx.post( f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"optional_field": json.dumps("has_value")}}, + json={"filament_id": random_filament["id"], "extra": {"dt_field": json.dumps(dt_early)}}, ) assert_httpx_success(result) spool_id1 = result.json()["id"] - # Spool 2 does NOT have the field set result = httpx.post( f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"]}, + json={"filament_id": random_filament["id"], "extra": {"dt_field": json.dumps(dt_late)}}, ) assert_httpx_success(result) spool_id2 = result.json()["id"] - # Filter by empty string — spool 2 (no field row) should appear, spool 1 should not - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.optional_field": ""}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id2 in ids - assert spool_id1 not in ids - - # Filter by the value — spool 1 should appear, spool 2 should not - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.optional_field": "has_value"}) + # Exact-match filter + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.dt_field": dt_early}) assert_httpx_success(result) ids = {item["id"] for item in result.json()} assert spool_id1 in ids assert spool_id2 not in ids - # Clean up - httpx.delete(f"{URL}/api/v1/field/spool/optional_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() - - -@pytest.mark.asyncio -async def test_invalid_numeric_custom_field_filters_return_400(): - """Invalid numeric custom-field filters should fail explicitly instead of being ignored.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/numeric_field", - json={ - "name": "Numeric field", - "field_type": "integer", - }, - ) + # Sort ascending: early before late (ISO 8601 sorts lexicographically) + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.dt_field:asc"}) assert_httpx_success(result) + test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] + assert len(test_spools) == 2 + assert test_spools[0]["id"] == spool_id1 + assert test_spools[1]["id"] == spool_id2 - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.numeric_field": "abc"}) - assert result.status_code == 400 - assert "Invalid integer filter value" in result.json()["message"] - - httpx.delete(f"{URL}/api/v1/field/spool/numeric_field").raise_for_status() - - -@pytest.mark.asyncio -async def test_invalid_boolean_custom_field_filters_return_400(): - """Invalid boolean custom-field filters should fail explicitly instead of being coerced.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/boolean_field", - json={ - "name": "Boolean field", - "field_type": "boolean", - }, - ) + # Sort descending + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.dt_field:desc"}) assert_httpx_success(result) + test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] + assert len(test_spools) == 2 + assert test_spools[0]["id"] == spool_id2 + assert test_spools[1]["id"] == spool_id1 - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.boolean_field": "maybe"}) - assert result.status_code == 400 - assert "Invalid boolean filter value" in result.json()["message"] + httpx.delete(f"{URL}/api/v1/field/spool/dt_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.boolean_field": "yes"}) - assert result.status_code == 400 - assert "Invalid boolean filter value" in result.json()["message"] - httpx.delete(f"{URL}/api/v1/field/spool/boolean_field").raise_for_status() +# --------------------------------------------------------------------------- +# Spool - integer_range +# --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_filter_sort_filament_custom_field(random_filament: dict[str, Any]): - """Test filtering and sorting filaments by a custom field.""" - vendor_id = random_filament["vendor"]["id"] - +async def test_filter_sort_integer_range_spool(random_filament: dict[str, Any]): + """Test filtering and sorting by a custom integer_range field on spools.""" result = httpx.post( - f"{URL}/api/v1/field/filament/filament_tag", - json={"name": "Filament tag", "field_type": "text"}, + f"{URL}/api/v1/field/spool/int_range_field", + json={"name": "Integer range field", "field_type": "integer_range"}, ) assert_httpx_success(result) result = httpx.post( - f"{URL}/api/v1/filament", - json={"vendor_id": vendor_id, "density": 1.24, "diameter": 1.75, "extra": {"filament_tag": json.dumps("beta")}}, + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"], "extra": {"int_range_field": json.dumps([100, 200])}}, ) assert_httpx_success(result) - filament_id1 = result.json()["id"] + spool_id1 = result.json()["id"] result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"filament_tag": json.dumps("alpha")}, - }, + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"], "extra": {"int_range_field": json.dumps([300, 400])}}, ) assert_httpx_success(result) - filament_id2 = result.json()["id"] + spool_id2 = result.json()["id"] - # Filter by custom field — only filament with "beta" should appear - result = httpx.get(f"{URL}/api/v1/filament", params={"extra.filament_tag": "beta"}) + # Filter by exact min:max + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.int_range_field": "100:200"}) assert_httpx_success(result) ids = {item["id"] for item in result.json()} - assert filament_id1 in ids - assert filament_id2 not in ids + assert spool_id1 in ids + assert spool_id2 not in ids - # Sort ascending: alpha before beta - result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.filament_tag:asc"}) + # Filter by min only + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.int_range_field": "100:"}) assert_httpx_success(result) - test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] - assert len(test_filaments) == 2 - assert test_filaments[0]["id"] == filament_id2 # alpha first - assert test_filaments[1]["id"] == filament_id1 # beta second + ids = {item["id"] for item in result.json()} + assert spool_id1 in ids + assert spool_id2 not in ids - # Sort descending: beta before alpha - result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.filament_tag:desc"}) + # Filter by max only + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.int_range_field": ":200"}) assert_httpx_success(result) - test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] - assert len(test_filaments) == 2 - assert test_filaments[0]["id"] == filament_id1 # beta first - assert test_filaments[1]["id"] == filament_id2 # alpha second + ids = {item["id"] for item in result.json()} + assert spool_id1 in ids + assert spool_id2 not in ids + + # Sort ascending by min (100 before 300) + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.int_range_field:asc"}) + assert_httpx_success(result) + test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] + assert len(test_spools) == 2 + assert test_spools[0]["id"] == spool_id1 + assert test_spools[1]["id"] == spool_id2 + + # Sort descending + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.int_range_field:desc"}) + assert_httpx_success(result) + test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] + assert len(test_spools) == 2 + assert test_spools[0]["id"] == spool_id2 + assert test_spools[1]["id"] == spool_id1 + + httpx.delete(f"{URL}/api/v1/field/spool/int_range_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() - # Clean up - httpx.delete(f"{URL}/api/v1/field/filament/filament_tag").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() + +# --------------------------------------------------------------------------- +# Spool - float_range +# --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_filter_sort_vendor_custom_field(): - """Test filtering and sorting vendors by a custom field.""" +async def test_filter_sort_float_range_spool(random_filament: dict[str, Any]): + """Test filtering and sorting by a custom float_range field on spools.""" result = httpx.post( - f"{URL}/api/v1/field/vendor/vendor_tier", - json={"name": "Vendor tier", "field_type": "text"}, + f"{URL}/api/v1/field/spool/float_range_field", + json={"name": "Float range field", "field_type": "float_range"}, ) assert_httpx_success(result) result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor Gold", "extra": {"vendor_tier": json.dumps("gold")}}, + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"], "extra": {"float_range_field": json.dumps([1.5, 2.5])}}, ) assert_httpx_success(result) - vendor_id1 = result.json()["id"] + spool_id1 = result.json()["id"] result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor Silver", "extra": {"vendor_tier": json.dumps("silver")}}, + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"], "extra": {"float_range_field": json.dumps([3.5, 4.5])}}, ) assert_httpx_success(result) - vendor_id2 = result.json()["id"] + spool_id2 = result.json()["id"] - # Filter by vendor custom field — only gold vendor should appear - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.vendor_tier": "gold"}) + # Filter by exact min:max + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_range_field": "1.5:2.5"}) assert_httpx_success(result) ids = {item["id"] for item in result.json()} - assert vendor_id1 in ids - assert vendor_id2 not in ids + assert spool_id1 in ids + assert spool_id2 not in ids - # Sort ascending: gold before silver - result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.vendor_tier:asc"}) + # Filter by min only + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_range_field": "1.5:"}) assert_httpx_success(result) - test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] - assert len(test_vendors) == 2 - assert test_vendors[0]["id"] == vendor_id1 # gold first - assert test_vendors[1]["id"] == vendor_id2 # silver second + ids = {item["id"] for item in result.json()} + assert spool_id1 in ids + assert spool_id2 not in ids - # Sort descending: silver before gold - result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.vendor_tier:desc"}) + # Sort ascending by min (1.5 before 3.5) + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.float_range_field:asc"}) assert_httpx_success(result) - test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] - assert len(test_vendors) == 2 - assert test_vendors[0]["id"] == vendor_id2 # silver first - assert test_vendors[1]["id"] == vendor_id1 # gold second - - # Clean up - httpx.delete(f"{URL}/api/v1/field/vendor/vendor_tier").raise_for_status() + test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] + assert len(test_spools) == 2 + assert test_spools[0]["id"] == spool_id1 + assert test_spools[1]["id"] == spool_id2 + + # Sort descending + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.float_range_field:desc"}) + assert_httpx_success(result) + test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] + assert len(test_spools) == 2 + assert test_spools[0]["id"] == spool_id2 + assert test_spools[1]["id"] == spool_id1 + + httpx.delete(f"{URL}/api/v1/field/spool/float_range_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Spool - empty filter +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_empty_custom_field(random_filament: dict[str, Any]): + """Test the empty-string filter returns items that have no value set for a custom field.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/optional_field", + json={"name": "Optional field", "field_type": "text"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"], "extra": {"optional_field": json.dumps("has_value")}}, + ) + assert_httpx_success(result) + spool_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"]}, + ) + assert_httpx_success(result) + spool_id2 = result.json()["id"] + + # Empty filter - spool without field should appear + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.optional_field": ""}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id2 in ids + assert spool_id1 not in ids + + # Value filter - spool with field should appear + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.optional_field": "has_value"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id1 in ids + assert spool_id2 not in ids + + httpx.delete(f"{URL}/api/v1/field/spool/optional_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Filament - text +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_sort_filament_custom_field(random_filament: dict[str, Any]): + """Test filtering and sorting filaments by a custom text field.""" + vendor_id = random_filament["vendor"]["id"] + + result = httpx.post( + f"{URL}/api/v1/field/filament/filament_tag", + json={"name": "Filament tag", "field_type": "text"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/filament", + json={"vendor_id": vendor_id, "density": 1.24, "diameter": 1.75, "extra": {"filament_tag": json.dumps("beta")}}, + ) + assert_httpx_success(result) + filament_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"filament_tag": json.dumps("alpha")}, + }, + ) + assert_httpx_success(result) + filament_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/filament", params={"extra.filament_tag": "beta"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert filament_id1 in ids + assert filament_id2 not in ids + + result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.filament_tag:asc"}) + assert_httpx_success(result) + test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] + assert len(test_filaments) == 2 + assert test_filaments[0]["id"] == filament_id2 # alpha first + assert test_filaments[1]["id"] == filament_id1 # beta second + + result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.filament_tag:desc"}) + assert_httpx_success(result) + test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] + assert len(test_filaments) == 2 + assert test_filaments[0]["id"] == filament_id1 # beta first + assert test_filaments[1]["id"] == filament_id2 # alpha second + + httpx.delete(f"{URL}/api/v1/field/filament/filament_tag").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Filament - integer +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_sort_filament_integer(random_filament: dict[str, Any]): + """Test filtering and sorting filaments by a custom integer field.""" + vendor_id = random_filament["vendor"]["id"] + + result = httpx.post( + f"{URL}/api/v1/field/filament/fil_int_field", + json={"name": "Filament integer field", "field_type": "integer"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/filament", + json={"vendor_id": vendor_id, "density": 1.24, "diameter": 1.75, "extra": {"fil_int_field": json.dumps(10)}}, + ) + assert_httpx_success(result) + filament_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/filament", + json={"vendor_id": vendor_id, "density": 1.24, "diameter": 1.75, "extra": {"fil_int_field": json.dumps(20)}}, + ) + assert_httpx_success(result) + filament_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_int_field": "10"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert filament_id1 in ids + assert filament_id2 not in ids + + result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_int_field:asc"}) + assert_httpx_success(result) + test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] + assert len(test_filaments) == 2 + assert test_filaments[0]["id"] == filament_id1 + assert test_filaments[1]["id"] == filament_id2 + + result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_int_field:desc"}) + assert_httpx_success(result) + test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] + assert len(test_filaments) == 2 + assert test_filaments[0]["id"] == filament_id2 + assert test_filaments[1]["id"] == filament_id1 + + httpx.delete(f"{URL}/api/v1/field/filament/fil_int_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Filament - float +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_sort_filament_float(random_filament: dict[str, Any]): + """Test filtering and sorting filaments by a custom float field.""" + vendor_id = random_filament["vendor"]["id"] + + result = httpx.post( + f"{URL}/api/v1/field/filament/fil_float_field", + json={"name": "Filament float field", "field_type": "float"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"fil_float_field": json.dumps(1.1)}, + }, + ) + assert_httpx_success(result) + filament_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"fil_float_field": json.dumps(9.9)}, + }, + ) + assert_httpx_success(result) + filament_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_float_field": "1.1"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert filament_id1 in ids + assert filament_id2 not in ids + + result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_float_field:asc"}) + assert_httpx_success(result) + test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] + assert len(test_filaments) == 2 + assert test_filaments[0]["id"] == filament_id1 + assert test_filaments[1]["id"] == filament_id2 + + result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_float_field:desc"}) + assert_httpx_success(result) + test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] + assert len(test_filaments) == 2 + assert test_filaments[0]["id"] == filament_id2 + assert test_filaments[1]["id"] == filament_id1 + + httpx.delete(f"{URL}/api/v1/field/filament/fil_float_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Filament - boolean +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_sort_filament_boolean(random_filament: dict[str, Any]): + """Test filtering and sorting filaments by a custom boolean field.""" + vendor_id = random_filament["vendor"]["id"] + + result = httpx.post( + f"{URL}/api/v1/field/filament/fil_bool_field", + json={"name": "Filament boolean field", "field_type": "boolean"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"fil_bool_field": json.dumps(bool(1))}, + }, + ) + assert_httpx_success(result) + filament_id_true = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"fil_bool_field": json.dumps(bool(0))}, + }, + ) + assert_httpx_success(result) + filament_id_false = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_bool_field": "true"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert filament_id_true in ids + assert filament_id_false not in ids + + result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_bool_field": "false"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert filament_id_false in ids + assert filament_id_true not in ids + + result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_bool_field:asc"}) + assert_httpx_success(result) + test_filaments = [item for item in result.json() if item["id"] in (filament_id_true, filament_id_false)] + assert len(test_filaments) == 2 + assert test_filaments[0]["id"] == filament_id_false + assert test_filaments[1]["id"] == filament_id_true + + httpx.delete(f"{URL}/api/v1/field/filament/fil_bool_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id_true}").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id_false}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Filament - single-choice +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_filament_single_choice(random_filament: dict[str, Any]): + """Test filtering filaments by a single-choice custom field.""" + vendor_id = random_filament["vendor"]["id"] + + result = httpx.post( + f"{URL}/api/v1/field/filament/fil_choice_field", + json={ + "name": "Filament choice field", + "field_type": "choice", + "choices": ["PLA", "PETG", "ABS"], + "multi_choice": False, + }, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"fil_choice_field": json.dumps("PLA")}, + }, + ) + assert_httpx_success(result) + filament_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"fil_choice_field": json.dumps("PETG")}, + }, + ) + assert_httpx_success(result) + filament_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_choice_field": "PLA"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert filament_id1 in ids + assert filament_id2 not in ids + + result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_choice_field": "PLA,PETG"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert filament_id1 in ids + assert filament_id2 in ids + + httpx.delete(f"{URL}/api/v1/field/filament/fil_choice_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Filament - multi-choice +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_filament_multi_choice(random_filament: dict[str, Any]): + """Test filtering filaments by a multi-choice custom field.""" + vendor_id = random_filament["vendor"]["id"] + + result = httpx.post( + f"{URL}/api/v1/field/filament/fil_multi_field", + json={ + "name": "Filament multi-choice", + "field_type": "choice", + "choices": ["X", "Y", "Z"], + "multi_choice": True, + }, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"fil_multi_field": json.dumps(["X", "Y"])}, + }, + ) + assert_httpx_success(result) + filament_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"fil_multi_field": json.dumps(["Z"])}, + }, + ) + assert_httpx_success(result) + filament_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_multi_field": "X"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert filament_id1 in ids + assert filament_id2 not in ids + + result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_multi_field": "Z"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert filament_id2 in ids + assert filament_id1 not in ids + + httpx.delete(f"{URL}/api/v1/field/filament/fil_multi_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Filament - datetime +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_sort_filament_datetime(random_filament: dict[str, Any]): + """Test filtering and sorting filaments by a custom datetime field.""" + vendor_id = random_filament["vendor"]["id"] + + result = httpx.post( + f"{URL}/api/v1/field/filament/fil_dt_field", + json={"name": "Filament datetime field", "field_type": "datetime"}, + ) + assert_httpx_success(result) + + dt_early = "2022-03-01T09:00:00" + dt_late = "2025-09-15T18:00:00" + + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"fil_dt_field": json.dumps(dt_early)}, + }, + ) + assert_httpx_success(result) + filament_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"fil_dt_field": json.dumps(dt_late)}, + }, + ) + assert_httpx_success(result) + filament_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_dt_field": dt_early}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert filament_id1 in ids + assert filament_id2 not in ids + + result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_dt_field:asc"}) + assert_httpx_success(result) + test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] + assert len(test_filaments) == 2 + assert test_filaments[0]["id"] == filament_id1 + assert test_filaments[1]["id"] == filament_id2 + + httpx.delete(f"{URL}/api/v1/field/filament/fil_dt_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Filament - integer_range +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_sort_filament_integer_range(random_filament: dict[str, Any]): + """Test filtering and sorting filaments by a custom integer_range field.""" + vendor_id = random_filament["vendor"]["id"] + + result = httpx.post( + f"{URL}/api/v1/field/filament/fil_int_range", + json={"name": "Filament integer range", "field_type": "integer_range"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"fil_int_range": json.dumps([200, 220])}, + }, + ) + assert_httpx_success(result) + filament_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"fil_int_range": json.dumps([240, 260])}, + }, + ) + assert_httpx_success(result) + filament_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_int_range": "200:220"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert filament_id1 in ids + assert filament_id2 not in ids + + result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_int_range:asc"}) + assert_httpx_success(result) + test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] + assert len(test_filaments) == 2 + assert test_filaments[0]["id"] == filament_id1 + assert test_filaments[1]["id"] == filament_id2 + + result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_int_range:desc"}) + assert_httpx_success(result) + test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] + assert len(test_filaments) == 2 + assert test_filaments[0]["id"] == filament_id2 + assert test_filaments[1]["id"] == filament_id1 + + httpx.delete(f"{URL}/api/v1/field/filament/fil_int_range").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Filament - float_range +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_sort_filament_float_range(random_filament: dict[str, Any]): + """Test filtering and sorting filaments by a custom float_range field.""" + vendor_id = random_filament["vendor"]["id"] + + result = httpx.post( + f"{URL}/api/v1/field/filament/fil_float_range", + json={"name": "Filament float range", "field_type": "float_range"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"fil_float_range": json.dumps([0.5, 1.0])}, + }, + ) + assert_httpx_success(result) + filament_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"fil_float_range": json.dumps([5.0, 7.5])}, + }, + ) + assert_httpx_success(result) + filament_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_float_range": "0.5:1.0"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert filament_id1 in ids + assert filament_id2 not in ids + + result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_float_range:asc"}) + assert_httpx_success(result) + test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] + assert len(test_filaments) == 2 + assert test_filaments[0]["id"] == filament_id1 + assert test_filaments[1]["id"] == filament_id2 + + httpx.delete(f"{URL}/api/v1/field/filament/fil_float_range").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Filament - empty filter +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_empty_filament_custom_field(random_filament: dict[str, Any]): + """Test the empty-string filter for filaments returns items with no value set.""" + vendor_id = random_filament["vendor"]["id"] + + result = httpx.post( + f"{URL}/api/v1/field/filament/fil_optional", + json={"name": "Optional filament field", "field_type": "text"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"fil_optional": json.dumps("set")}, + }, + ) + assert_httpx_success(result) + filament_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/filament", + json={"vendor_id": vendor_id, "density": 1.24, "diameter": 1.75}, + ) + assert_httpx_success(result) + filament_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_optional": ""}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert filament_id2 in ids + assert filament_id1 not in ids + + result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_optional": "set"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert filament_id1 in ids + assert filament_id2 not in ids + + httpx.delete(f"{URL}/api/v1/field/filament/fil_optional").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Vendor - text +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_sort_vendor_custom_field(): + """Test filtering and sorting vendors by a custom text field.""" + result = httpx.post( + f"{URL}/api/v1/field/vendor/vendor_tier", + json={"name": "Vendor tier", "field_type": "text"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor Gold", "extra": {"vendor_tier": json.dumps("gold")}}, + ) + assert_httpx_success(result) + vendor_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor Silver", "extra": {"vendor_tier": json.dumps("silver")}}, + ) + assert_httpx_success(result) + vendor_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.vendor_tier": "gold"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert vendor_id1 in ids + assert vendor_id2 not in ids + + result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.vendor_tier:asc"}) + assert_httpx_success(result) + test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] + assert len(test_vendors) == 2 + assert test_vendors[0]["id"] == vendor_id1 # gold first + assert test_vendors[1]["id"] == vendor_id2 # silver second + + result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.vendor_tier:desc"}) + assert_httpx_success(result) + test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] + assert len(test_vendors) == 2 + assert test_vendors[0]["id"] == vendor_id2 # silver first + assert test_vendors[1]["id"] == vendor_id1 # gold second + + httpx.delete(f"{URL}/api/v1/field/vendor/vendor_tier").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() + + + +# --------------------------------------------------------------------------- +# Vendor - integer +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_sort_vendor_integer(): + """Test filtering and sorting vendors by a custom integer field.""" + result = httpx.post( + f"{URL}/api/v1/field/vendor/ven_int_field", + json={"name": "Vendor integer field", "field_type": "integer"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor A Int", "extra": {"ven_int_field": json.dumps(5)}}, + ) + assert_httpx_success(result) + vendor_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor B Int", "extra": {"ven_int_field": json.dumps(50)}}, + ) + assert_httpx_success(result) + vendor_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_int_field": "5"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert vendor_id1 in ids + assert vendor_id2 not in ids + + result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_int_field:asc"}) + assert_httpx_success(result) + test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] + assert len(test_vendors) == 2 + assert test_vendors[0]["id"] == vendor_id1 + assert test_vendors[1]["id"] == vendor_id2 + + result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_int_field:desc"}) + assert_httpx_success(result) + test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] + assert len(test_vendors) == 2 + assert test_vendors[0]["id"] == vendor_id2 + assert test_vendors[1]["id"] == vendor_id1 + + httpx.delete(f"{URL}/api/v1/field/vendor/ven_int_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Vendor - float +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_sort_vendor_float(): + """Test filtering and sorting vendors by a custom float field.""" + result = httpx.post( + f"{URL}/api/v1/field/vendor/ven_float_field", + json={"name": "Vendor float field", "field_type": "float"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor A Float", "extra": {"ven_float_field": json.dumps(0.1)}}, + ) + assert_httpx_success(result) + vendor_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor B Float", "extra": {"ven_float_field": json.dumps(9.9)}}, + ) + assert_httpx_success(result) + vendor_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_float_field": "0.1"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert vendor_id1 in ids + assert vendor_id2 not in ids + + result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_float_field:asc"}) + assert_httpx_success(result) + test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] + assert len(test_vendors) == 2 + assert test_vendors[0]["id"] == vendor_id1 + assert test_vendors[1]["id"] == vendor_id2 + + httpx.delete(f"{URL}/api/v1/field/vendor/ven_float_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Vendor - boolean +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_sort_vendor_boolean(): + """Test filtering and sorting vendors by a custom boolean field.""" + result = httpx.post( + f"{URL}/api/v1/field/vendor/ven_bool_field", + json={"name": "Vendor boolean field", "field_type": "boolean"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor Active", "extra": {"ven_bool_field": json.dumps(bool(1))}}, + ) + assert_httpx_success(result) + vendor_id_true = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor Inactive", "extra": {"ven_bool_field": json.dumps(bool(0))}}, + ) + assert_httpx_success(result) + vendor_id_false = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_bool_field": "true"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert vendor_id_true in ids + assert vendor_id_false not in ids + + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_bool_field": "false"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert vendor_id_false in ids + assert vendor_id_true not in ids + + result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_bool_field:asc"}) + assert_httpx_success(result) + test_vendors = [item for item in result.json() if item["id"] in (vendor_id_true, vendor_id_false)] + assert len(test_vendors) == 2 + assert test_vendors[0]["id"] == vendor_id_false + assert test_vendors[1]["id"] == vendor_id_true + + httpx.delete(f"{URL}/api/v1/field/vendor/ven_bool_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id_true}").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id_false}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Vendor - single-choice +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_sort_vendor_single_choice(): + """Test filtering and sorting vendors by a single-choice custom field.""" + result = httpx.post( + f"{URL}/api/v1/field/vendor/ven_choice_field", + json={ + "name": "Vendor choice field", + "field_type": "choice", + "choices": ["Bronze", "Silver", "Gold"], + "multi_choice": False, + }, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor Bronze", "extra": {"ven_choice_field": json.dumps("Bronze")}}, + ) + assert_httpx_success(result) + vendor_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor Gold", "extra": {"ven_choice_field": json.dumps("Gold")}}, + ) + assert_httpx_success(result) + vendor_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_choice_field": "Bronze"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert vendor_id1 in ids + assert vendor_id2 not in ids + + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_choice_field": "Bronze,Gold"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert vendor_id1 in ids + assert vendor_id2 in ids + + result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_choice_field:asc"}) + assert_httpx_success(result) + test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] + assert len(test_vendors) == 2 + assert test_vendors[0]["id"] == vendor_id1 # Bronze first + assert test_vendors[1]["id"] == vendor_id2 # Gold second + + httpx.delete(f"{URL}/api/v1/field/vendor/ven_choice_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Vendor - multi-choice +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_vendor_multi_choice(): + """Test filtering vendors by a multi-choice custom field.""" + result = httpx.post( + f"{URL}/api/v1/field/vendor/ven_multi_field", + json={ + "name": "Vendor multi-choice", + "field_type": "choice", + "choices": ["EU", "US", "ASIA"], + "multi_choice": True, + }, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor EU+US", "extra": {"ven_multi_field": json.dumps(["EU", "US"])}}, + ) + assert_httpx_success(result) + vendor_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor ASIA", "extra": {"ven_multi_field": json.dumps(["ASIA"])}}, + ) + assert_httpx_success(result) + vendor_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_multi_field": "EU"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert vendor_id1 in ids + assert vendor_id2 not in ids + + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_multi_field": "ASIA"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert vendor_id2 in ids + assert vendor_id1 not in ids + + httpx.delete(f"{URL}/api/v1/field/vendor/ven_multi_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Vendor - datetime +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_sort_vendor_datetime(): + """Test filtering and sorting vendors by a custom datetime field.""" + result = httpx.post( + f"{URL}/api/v1/field/vendor/ven_dt_field", + json={"name": "Vendor datetime field", "field_type": "datetime"}, + ) + assert_httpx_success(result) + + dt_early = "2020-01-01T00:00:00" + dt_late = "2026-12-31T23:59:59" + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor Old", "extra": {"ven_dt_field": json.dumps(dt_early)}}, + ) + assert_httpx_success(result) + vendor_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor New", "extra": {"ven_dt_field": json.dumps(dt_late)}}, + ) + assert_httpx_success(result) + vendor_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_dt_field": dt_early}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert vendor_id1 in ids + assert vendor_id2 not in ids + + result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_dt_field:asc"}) + assert_httpx_success(result) + test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] + assert len(test_vendors) == 2 + assert test_vendors[0]["id"] == vendor_id1 + assert test_vendors[1]["id"] == vendor_id2 + + result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_dt_field:desc"}) + assert_httpx_success(result) + test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] + assert len(test_vendors) == 2 + assert test_vendors[0]["id"] == vendor_id2 + assert test_vendors[1]["id"] == vendor_id1 + + httpx.delete(f"{URL}/api/v1/field/vendor/ven_dt_field").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Vendor - integer_range +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_sort_vendor_integer_range(): + """Test filtering and sorting vendors by a custom integer_range field.""" + result = httpx.post( + f"{URL}/api/v1/field/vendor/ven_int_range", + json={"name": "Vendor integer range", "field_type": "integer_range"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor Range Low", "extra": {"ven_int_range": json.dumps([10, 20])}}, + ) + assert_httpx_success(result) + vendor_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor Range High", "extra": {"ven_int_range": json.dumps([90, 100])}}, + ) + assert_httpx_success(result) + vendor_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_int_range": "10:20"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert vendor_id1 in ids + assert vendor_id2 not in ids + + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_int_range": "10:"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert vendor_id1 in ids + assert vendor_id2 not in ids + + result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_int_range:asc"}) + assert_httpx_success(result) + test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] + assert len(test_vendors) == 2 + assert test_vendors[0]["id"] == vendor_id1 + assert test_vendors[1]["id"] == vendor_id2 + + result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_int_range:desc"}) + assert_httpx_success(result) + test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] + assert len(test_vendors) == 2 + assert test_vendors[0]["id"] == vendor_id2 + assert test_vendors[1]["id"] == vendor_id1 + + httpx.delete(f"{URL}/api/v1/field/vendor/ven_int_range").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Vendor - float_range +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_sort_vendor_float_range(): + """Test filtering and sorting vendors by a custom float_range field.""" + result = httpx.post( + f"{URL}/api/v1/field/vendor/ven_float_range", + json={"name": "Vendor float range", "field_type": "float_range"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor Range Small", "extra": {"ven_float_range": json.dumps([0.1, 0.5])}}, + ) + assert_httpx_success(result) + vendor_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor Range Large", "extra": {"ven_float_range": json.dumps([10.0, 20.0])}}, + ) + assert_httpx_success(result) + vendor_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_float_range": "0.1:0.5"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert vendor_id1 in ids + assert vendor_id2 not in ids + + result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_float_range:asc"}) + assert_httpx_success(result) + test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] + assert len(test_vendors) == 2 + assert test_vendors[0]["id"] == vendor_id1 + assert test_vendors[1]["id"] == vendor_id2 + + result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_float_range:desc"}) + assert_httpx_success(result) + test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] + assert len(test_vendors) == 2 + assert test_vendors[0]["id"] == vendor_id2 + assert test_vendors[1]["id"] == vendor_id1 + + httpx.delete(f"{URL}/api/v1/field/vendor/ven_float_range").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Vendor - empty filter +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_filter_empty_vendor_custom_field(): + """Test the empty-string filter for vendors returns items with no value set.""" + result = httpx.post( + f"{URL}/api/v1/field/vendor/ven_optional", + json={"name": "Optional vendor field", "field_type": "text"}, + ) + assert_httpx_success(result) + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor With Value", "extra": {"ven_optional": json.dumps("present")}}, + ) + assert_httpx_success(result) + vendor_id1 = result.json()["id"] + + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": "Vendor Without Value"}, + ) + assert_httpx_success(result) + vendor_id2 = result.json()["id"] + + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_optional": ""}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert vendor_id2 in ids + assert vendor_id1 not in ids + + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_optional": "present"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert vendor_id1 in ids + assert vendor_id2 not in ids + + httpx.delete(f"{URL}/api/v1/field/vendor/ven_optional").raise_for_status() httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() + + +# --------------------------------------------------------------------------- +# Invalid filter values → 400 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_invalid_numeric_custom_field_filters_return_400(): + """Invalid numeric custom-field filters should fail explicitly instead of being ignored.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/numeric_field_400", + json={"name": "Numeric field", "field_type": "integer"}, + ) + assert_httpx_success(result) + + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.numeric_field_400": "abc"}) + assert result.status_code == 400 + assert "Invalid integer filter value" in result.json()["message"] + + httpx.delete(f"{URL}/api/v1/field/spool/numeric_field_400").raise_for_status() + + +@pytest.mark.asyncio +async def test_invalid_boolean_custom_field_filters_return_400(): + """Invalid boolean custom-field filters should fail explicitly instead of being coerced.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/boolean_field_400", + json={"name": "Boolean field", "field_type": "boolean"}, + ) + assert_httpx_success(result) + + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.boolean_field_400": "maybe"}) + assert result.status_code == 400 + assert "Invalid boolean filter value" in result.json()["message"] + + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.boolean_field_400": "yes"}) + assert result.status_code == 400 + assert "Invalid boolean filter value" in result.json()["message"] + + httpx.delete(f"{URL}/api/v1/field/spool/boolean_field_400").raise_for_status() + + +@pytest.mark.asyncio +async def test_invalid_float_custom_field_filter_returns_400(): + """Invalid float custom-field filters should fail with a 400 error.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/float_field_400", + json={"name": "Float field 400", "field_type": "float"}, + ) + assert_httpx_success(result) + + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_field_400": "notafloat"}) + assert result.status_code == 400 + assert "Invalid float filter value" in result.json()["message"] + + httpx.delete(f"{URL}/api/v1/field/spool/float_field_400").raise_for_status() + + +@pytest.mark.asyncio +async def test_invalid_integer_range_custom_field_filter_returns_400(): + """Invalid integer_range custom-field filters should fail with a 400 error.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/int_range_400", + json={"name": "Integer range 400", "field_type": "integer_range"}, + ) + assert_httpx_success(result) + + # Missing colon separator + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.int_range_400": "100"}) + assert result.status_code == 400 + assert "Invalid range filter value" in result.json()["message"] + + # Non-numeric min value + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.int_range_400": "abc:200"}) + assert result.status_code == 400 + assert "Invalid range filter value" in result.json()["message"] + + httpx.delete(f"{URL}/api/v1/field/spool/int_range_400").raise_for_status() + + +@pytest.mark.asyncio +async def test_invalid_float_range_custom_field_filter_returns_400(): + """Invalid float_range custom-field filters should fail with a 400 error.""" + result = httpx.post( + f"{URL}/api/v1/field/spool/float_range_400", + json={"name": "Float range 400", "field_type": "float_range"}, + ) + assert_httpx_success(result) + + # Missing colon separator + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_range_400": "1.5"}) + assert result.status_code == 400 + assert "Invalid range filter value" in result.json()["message"] + + # Non-numeric min value + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_range_400": "notanum:2.5"}) + assert result.status_code == 400 + assert "Invalid range filter value" in result.json()["message"] + + httpx.delete(f"{URL}/api/v1/field/spool/float_range_400").raise_for_status() From 86d0ae1e0482211ae8d43c5a0124bc174cbf9b20 Mon Sep 17 00:00:00 2001 From: Dieter Blomme Date: Sun, 29 Mar 2026 17:13:52 +0200 Subject: [PATCH 10/16] Add filter UI for all custom field types and fix range filters - Add text input filter dropdown for text fields (substring search) - Add range input filter dropdowns (min/max) for integer, float, integer_range, and float_range fields - Add datetime picker filter dropdown for datetime fields - Fix boolean "No" filter to use empty value so it correctly matches unset/false fields - Fix integer_range/float_range filter to use numeric comparisons instead of LIKE-based exact matching (stored_min >= filter_min, stored_max <= filter_max) - Add range filter support for integer/float fields (min:max format) - Add _JsonArraySecondElement cross-database helper for extracting second JSON array element --- client/src/components/column.tsx | 175 +++++++++++++++++++++---- spoolman/database/extra_field_query.py | 36 ++++- 2 files changed, 180 insertions(+), 31 deletions(-) diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index d5a5f7658..4196ba9a7 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -1,7 +1,7 @@ import { DateField, TextField } from "@refinedev/antd"; import { UseQueryResult } from "@tanstack/react-query"; -import { Button, Col, Dropdown, Row, Space, Spin } from "antd"; -import { ColumnFilterItem, ColumnType } from "antd/es/table/interface"; +import { Button, Col, DatePicker, Dropdown, Input, InputNumber, Row, Space, Spin } from "antd"; +import { ColumnFilterItem, ColumnType, FilterDropdownProps } from "antd/es/table/interface"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { AlignType } from "rc-table/lib/interface"; @@ -27,6 +27,112 @@ const FilterDropdownLoading = () => { ); }; +function TextFilterDropdown({ setSelectedKeys, selectedKeys, confirm, clearFilters }: FilterDropdownProps) { + return ( +
+ setSelectedKeys(e.target.value ? [e.target.value] : [])} + onPressEnter={() => confirm()} + style={{ marginBottom: 8, display: "block" }} + /> + + + + +
+ ); +} + + +function NumberRangeFilterDropdown({ + setSelectedKeys, + selectedKeys, + confirm, + clearFilters, + precision, +}: FilterDropdownProps & { precision?: number }) { + const current = selectedKeys[0] as string | undefined; + let minVal: number | null = null; + let maxVal: number | null = null; + if (current && current.includes(":")) { + const parts = current.split(":", 2); + minVal = parts[0] ? Number(parts[0]) : null; + maxVal = parts[1] ? Number(parts[1]) : null; + } + + const updateKeys = (min: number | null, max: number | null) => { + if (min === null && max === null) { + setSelectedKeys([]); + } else { + setSelectedKeys([`${min !== null ? min : ""}:${max !== null ? max : ""}`]); + } + }; + + return ( +
+ + updateKeys(value, maxVal)} + /> + updateKeys(minVal, value)} + /> + + + + + +
+ ); +} + +function DateTimeFilterDropdown({ setSelectedKeys, selectedKeys, confirm, clearFilters }: FilterDropdownProps) { + const current = selectedKeys[0] as string | undefined; + const value = current ? dayjs.utc(current) : null; + + return ( +
+ { + if (date) { + setSelectedKeys([date.utc().toISOString()]); + } else { + setSelectedKeys([]); + } + }} + style={{ marginBottom: 8, display: "block" }} + /> + + + + +
+ ); +} + interface Entity { id: number; } @@ -62,6 +168,7 @@ interface FilteredColumnProps { allowMultipleFilters?: boolean; onFilterDropdownOpen?: () => void; loadingFilters?: boolean; + filterDropdown?: (props: FilterDropdownProps) => React.ReactNode; } interface CustomColumnProps { @@ -119,6 +226,13 @@ function Column( if (props.dataId) { columnProps.key = props.dataId; } + } else if (props.filterDropdown) { + columnProps.filterDropdown = props.filterDropdown; + columnProps.filteredValue = props.filteredValue; + columnProps.filterMultiple = false; + if (props.dataId) { + columnProps.key = props.dataId; + } } // Render @@ -172,7 +286,7 @@ export function SortedColumn(props: BaseColumnProps) { } export function RichColumn( - props: Omit, "transform"> & { transform?: (value: unknown) => string }, + props: Omit, "transform"> & FilteredColumnProps & { transform?: (value: unknown) => string }, ) { return Column({ ...props, @@ -219,7 +333,7 @@ export function FilteredQueryColumn(props: FilteredQueryColu return Column({ ...props, filters, filteredValue, onFilterDropdownOpen, loadingFilters: query.isLoading }); } -interface NumberColumnProps extends BaseColumnProps { +interface NumberColumnProps extends BaseColumnProps, FilteredColumnProps { unit: string; maxDecimals?: number; minDecimals?: number; @@ -249,7 +363,7 @@ export function NumberColumn(props: NumberColumnProps) }); } -export function DateColumn(props: BaseColumnProps) { +export function DateColumn(props: BaseColumnProps & FilteredColumnProps) { return Column({ ...props, render: (rawValue) => { @@ -389,30 +503,29 @@ export function NumberRangeColumn(props: NumberColumnProps { filters.push({ text: choice, - value: `"${choice}"`, // Exact match + value: `"${choice}"`, }); }); } - // For boolean fields, add true/false options - if (field.field_type === FieldType.boolean) { - filters.push({ text: "Yes", value: "true" }, { text: "No", value: "false" }); - } - - // Add empty option for all field types - filters.push({ - text: "", - value: "", - }); + filters.push({ text: "", value: "" }); return filters; } @@ -421,21 +534,15 @@ export function CustomFieldColumn(props: Omit(props.tableState.filters); const filteredValue = getFiltersForField(typedFilters, fieldId); - // Create filters based on field type - const filters = createCustomFieldFilters(field); - const commonProps = { ...props, id: ["extra", field.key], title: field.name, - sorter: true, // Enable sorting for custom fields - dataId: fieldId, // Set the dataId for sorting - filters: filters, // Add filters - filteredValue: filteredValue, // Set filtered values + sorter: true, + dataId: fieldId, transform: (value: unknown) => { if (value === null || value === undefined) { return undefined; @@ -447,12 +554,16 @@ export function CustomFieldColumn(props: Omit , + filteredValue, unit: field.unit ?? "", maxDecimals: 0, }); } else if (field.field_type === FieldType.float) { return NumberColumn({ ...commonProps, + filterDropdown: (p: FilterDropdownProps) => , + filteredValue, unit: field.unit ?? "", minDecimals: 0, maxDecimals: 3, @@ -460,12 +571,16 @@ export function CustomFieldColumn(props: Omit , + filteredValue, unit: field.unit ?? "", maxDecimals: 0, }); } else if (field.field_type === FieldType.float_range) { return NumberRangeColumn({ ...commonProps, + filterDropdown: (p: FilterDropdownProps) => , + filteredValue, unit: field.unit ?? "", minDecimals: 0, maxDecimals: 3, @@ -473,14 +588,20 @@ export function CustomFieldColumn(props: Omit { const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue; let text; @@ -497,6 +618,8 @@ export function CustomFieldColumn(props: Omit { const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue; return ; @@ -505,6 +628,8 @@ export function CustomFieldColumn(props: Omit { const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue; return ; diff --git a/spoolman/database/extra_field_query.py b/spoolman/database/extra_field_query.py index 8c238725a..dce2253ff 100644 --- a/spoolman/database/extra_field_query.py +++ b/spoolman/database/extra_field_query.py @@ -42,6 +42,29 @@ def _compile_json_array_first_default(element: _JsonArrayFirstElement, compiler: return f"JSON_EXTRACT({col_sql}, '$[0]')" +class _JsonArraySecondElement(FunctionElement): + """Cross-database helper: return the second element of a JSON array stored as text.""" + + name = "json_array_second_element" + inherit_cache = True + + +@compiles(_JsonArraySecondElement, "postgresql") +def _compile_json_array_second_pg(element: _JsonArraySecondElement, compiler: object, **kw: object) -> str: # type: ignore[misc] + """PostgreSQL: CAST(value AS JSON)->>'1' returns the second element as TEXT.""" + (col_expr,) = element.clauses + col_sql = compiler.process(col_expr, **kw) # type: ignore[union-attr] + return f"(CAST({col_sql} AS JSON)->>1)" + + +@compiles(_JsonArraySecondElement) +def _compile_json_array_second_default(element: _JsonArraySecondElement, compiler: object, **kw: object) -> str: # type: ignore[misc] + """SQLite/MariaDB: json_extract(value, '$[1]') returns the second element as a scalar.""" + (col_expr,) = element.clauses + col_sql = compiler.process(col_expr, **kw) # type: ignore[union-attr] + return f"JSON_EXTRACT({col_sql}, '$[1]')" + + def _get_field_table_for_entity(entity_type: EntityType) -> type[models.Base]: """Map an entity type to its extra-field table.""" if entity_type == EntityType.spool: @@ -200,14 +223,15 @@ def add_where_clause_extra_field( # noqa: C901, PLR0912, PLR0915 converter = int if field_type == ExtraFieldType.integer_range else float range_conditions = [] try: + cast_type = sqlalchemy.Integer if field_type == ExtraFieldType.integer_range else sqlalchemy.Float if min_val_str: - range_field = field_table.value[0] - cast_type = sqlalchemy.Integer if field_type == ExtraFieldType.integer_range else sqlalchemy.Float - range_conditions.append(sqlalchemy.cast(range_field, cast_type) >= converter(min_val_str)) + # stored_min >= filter_min: the range starts at or after the requested minimum. + stored_min = sqlalchemy.cast(_JsonArrayFirstElement(field_table.value), cast_type) + range_conditions.append(stored_min >= converter(min_val_str)) if max_val_str: - range_field = field_table.value[1] - cast_type = sqlalchemy.Integer if field_type == ExtraFieldType.integer_range else sqlalchemy.Float - range_conditions.append(sqlalchemy.cast(range_field, cast_type) <= converter(max_val_str)) + # stored_max <= filter_max: the range ends at or before the requested maximum. + stored_max = sqlalchemy.cast(_JsonArraySecondElement(field_table.value), cast_type) + range_conditions.append(stored_max <= converter(max_val_str)) except (ValueError, TypeError) as exc: range_kind = "integer" if field_type == ExtraFieldType.integer_range else "float" raise ValueError(f"Invalid {range_kind} range filter value for '{field_key}': {parsed_value}") from exc From 884f11a0dbd67be2f327fa798f992dcf394e28b4 Mon Sep 17 00:00:00 2001 From: Dieter Blomme Date: Sun, 29 Mar 2026 17:29:37 +0200 Subject: [PATCH 11/16] Range filter for integer/float fields and fix range_field filter semantics Backend: - Add _JsonArraySecondElement cross-database helper (mirrors _JsonArrayFirstElement) - integer_range/float_range filter now uses numeric comparisons: stored_min >= filter_min and stored_max <= filter_max (was LIKE exact match) - integer/float filter now supports min:max range format in addition to exact match: stored_value >= min and/or stored_value <= max Frontend: - integer and float fields now use a range filter dropdown (min/max inputs) instead of a single exact-value input Tests: - Fix integer_range/float_range spool and vendor tests broken by new >= semantics (filter values updated to discriminate between test entries) - Add range filter tests (min only, max only, both) for integer and float fields --- .../tests/fields/test_filter_sort.py | 75 ++++++++++++++++--- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/tests_integration/tests/fields/test_filter_sort.py b/tests_integration/tests/fields/test_filter_sort.py index b9d1aa159..ba590a3e4 100644 --- a/tests_integration/tests/fields/test_filter_sort.py +++ b/tests_integration/tests/fields/test_filter_sort.py @@ -142,6 +142,27 @@ async def test_filter_by_numeric_custom_field(random_filament: dict[str, Any]): assert len(data) == 1 assert data[0]["id"] == spool_id1 + # Range filter - min only: stored_value >= 150 matches only spool2 (200) + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.numeric_field": "150:"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id1 not in ids + assert spool_id2 in ids + + # Range filter - max only: stored_value <= 150 matches only spool1 (100) + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.numeric_field": ":150"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id1 in ids + assert spool_id2 not in ids + + # Range filter - min and max: 50 <= stored_value <= 150 matches only spool1 (100) + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.numeric_field": "50:150"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id1 in ids + assert spool_id2 not in ids + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.numeric_field:asc"}) assert_httpx_success(result) test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] @@ -195,6 +216,27 @@ async def test_filter_and_sort_float_custom_field(random_filament: dict[str, Any assert spool_id1 in ids assert spool_id2 not in ids + # Range filter - min only: stored_value >= 2.0 matches only spool2 (2.5) + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_field": "2.0:"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id1 not in ids + assert spool_id2 in ids + + # Range filter - max only: stored_value <= 2.0 matches only spool1 (1.5) + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_field": ":2.0"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id1 in ids + assert spool_id2 not in ids + + # Range filter - both: 1.0 <= stored_value <= 2.0 matches only spool1 (1.5) + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_field": "1.0:2.0"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id1 in ids + assert spool_id2 not in ids + result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.float_field:asc"}) assert_httpx_success(result) test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] @@ -498,15 +540,15 @@ async def test_filter_sort_integer_range_spool(random_filament: dict[str, Any]): assert spool_id1 in ids assert spool_id2 not in ids - # Filter by min only - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.int_range_field": "100:"}) + # Filter by min only: stored_min >= 200 matches only spool2 ([300,400]) + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.int_range_field": "200:"}) assert_httpx_success(result) ids = {item["id"] for item in result.json()} - assert spool_id1 in ids - assert spool_id2 not in ids + assert spool_id1 not in ids + assert spool_id2 in ids - # Filter by max only - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.int_range_field": ":200"}) + # Filter by max only: stored_max <= 300 matches only spool1 ([100,200]) + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.int_range_field": ":300"}) assert_httpx_success(result) ids = {item["id"] for item in result.json()} assert spool_id1 in ids @@ -568,8 +610,15 @@ async def test_filter_sort_float_range_spool(random_filament: dict[str, Any]): assert spool_id1 in ids assert spool_id2 not in ids - # Filter by min only - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_range_field": "1.5:"}) + # Filter by min only: stored_min >= 2.5 matches only spool2 ([3.5,4.5]) + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_range_field": "2.5:"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert spool_id1 not in ids + assert spool_id2 in ids + + # Filter by max only: stored_max <= 3.5 matches only spool1 ([1.5,2.5]) + result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_range_field": ":3.5"}) assert_httpx_success(result) ids = {item["id"] for item in result.json()} assert spool_id1 in ids @@ -1649,7 +1698,15 @@ async def test_filter_sort_vendor_integer_range(): assert vendor_id1 in ids assert vendor_id2 not in ids - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_int_range": "10:"}) + # Filter by min only: stored_min >= 50 matches only vendor2 ([90,100]) + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_int_range": "50:"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert vendor_id1 not in ids + assert vendor_id2 in ids + + # Filter by max only: stored_max <= 50 matches only vendor1 ([10,20]) + result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_int_range": ":50"}) assert_httpx_success(result) ids = {item["id"] for item in result.json()} assert vendor_id1 in ids From f718d8aeada069b238dc002878fa63bec716069d Mon Sep 17 00:00:00 2001 From: Dieter Blomme Date: Sun, 29 Mar 2026 17:40:07 +0200 Subject: [PATCH 12/16] Replace 12 copy-pasted numeric field tests with 4 parametrized tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a _create_entity helper and @pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) to run each numeric field type test against all three entity types from a single test function. - Removes 12 individual tests (integer/float/integer_range/float_range × 3 entities) - Adds 4 parametrized tests covering the same ground plus the previously missing min-only / max-only range filter cases for filament and vendor - Uses try/finally for cleanup so entities are always deleted even on assertion failure --- .../tests/fields/test_filter_sort.py | 1042 +++++------------ 1 file changed, 280 insertions(+), 762 deletions(-) diff --git a/tests_integration/tests/fields/test_filter_sort.py b/tests_integration/tests/fields/test_filter_sort.py index ba590a3e4..cacadf610 100644 --- a/tests_integration/tests/fields/test_filter_sort.py +++ b/tests_integration/tests/fields/test_filter_sort.py @@ -1,6 +1,7 @@ """Tests for filtering and sorting by custom fields.""" import json +import uuid from typing import Any import httpx @@ -8,6 +9,273 @@ from ..conftest import URL, assert_httpx_success + +def _create_entity(entity_type: str, extra: dict[str, str], random_filament: dict[str, Any]) -> int: + """Create a test entity of the given type with the given extra fields. Returns the entity id.""" + if entity_type == "spool": + result = httpx.post( + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"], "extra": extra}, + ) + elif entity_type == "filament": + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": random_filament["vendor"]["id"], + "name": f"Test-{uuid.uuid4().hex[:8]}", + "density": 1.24, + "diameter": 1.75, + "extra": extra, + }, + ) + elif entity_type == "vendor": + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": f"Vendor-{uuid.uuid4().hex[:8]}", "extra": extra}, + ) + else: + raise ValueError(f"Unknown entity type: {entity_type}") + result.raise_for_status() + return result.json()["id"] + + +# --------------------------------------------------------------------------- +# Numeric fields - integer, float, integer_range, float_range (all entity types) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) +async def test_integer_filter_and_sort(entity_type: str, random_filament: dict[str, Any]) -> None: + """Test filter and sort by a custom integer field for all entity types.""" + field_key = "test_int_field" + httpx.post( + f"{URL}/api/v1/field/{entity_type}/{field_key}", + json={"name": "Integer field", "field_type": "integer"}, + ).raise_for_status() + id1 = _create_entity(entity_type, {field_key: json.dumps(100)}, random_filament) + id2 = _create_entity(entity_type, {field_key: json.dumps(200)}, random_filament) + try: + # Exact match + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "100"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids + + # Range: min only (>= 150 matches only id2=200) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "150:"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 not in ids + assert id2 in ids + + # Range: max only (<= 150 matches only id1=100) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": ":150"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids + + # Range: both bounds (50 <= x <= 150 matches only id1=100) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "50:150"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids + + # Sort ascending (100 before 200) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={"sort": f"extra.{field_key}:asc"}) + assert_httpx_success(result) + ordered = [item for item in result.json() if item["id"] in (id1, id2)] + assert len(ordered) == 2 + assert ordered[0]["id"] == id1 + assert ordered[1]["id"] == id2 + + # Sort descending + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={"sort": f"extra.{field_key}:desc"}) + assert_httpx_success(result) + ordered = [item for item in result.json() if item["id"] in (id1, id2)] + assert len(ordered) == 2 + assert ordered[0]["id"] == id2 + assert ordered[1]["id"] == id1 + finally: + httpx.delete(f"{URL}/api/v1/field/{entity_type}/{field_key}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id2}").raise_for_status() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) +async def test_float_filter_and_sort(entity_type: str, random_filament: dict[str, Any]) -> None: + """Test filter and sort by a custom float field for all entity types.""" + field_key = "test_float_field" + httpx.post( + f"{URL}/api/v1/field/{entity_type}/{field_key}", + json={"name": "Float field", "field_type": "float"}, + ).raise_for_status() + id1 = _create_entity(entity_type, {field_key: json.dumps(1.5)}, random_filament) + id2 = _create_entity(entity_type, {field_key: json.dumps(2.5)}, random_filament) + try: + # Exact match + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "1.5"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids + + # Range: min only (>= 2.0 matches only id2=2.5) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "2.0:"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 not in ids + assert id2 in ids + + # Range: max only (<= 2.0 matches only id1=1.5) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": ":2.0"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids + + # Range: both bounds (1.0 <= x <= 2.0 matches only id1=1.5) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "1.0:2.0"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids + + # Sort ascending (1.5 before 2.5) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={"sort": f"extra.{field_key}:asc"}) + assert_httpx_success(result) + ordered = [item for item in result.json() if item["id"] in (id1, id2)] + assert len(ordered) == 2 + assert ordered[0]["id"] == id1 + assert ordered[1]["id"] == id2 + + # Sort descending + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={"sort": f"extra.{field_key}:desc"}) + assert_httpx_success(result) + ordered = [item for item in result.json() if item["id"] in (id1, id2)] + assert len(ordered) == 2 + assert ordered[0]["id"] == id2 + assert ordered[1]["id"] == id1 + finally: + httpx.delete(f"{URL}/api/v1/field/{entity_type}/{field_key}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id2}").raise_for_status() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) +async def test_integer_range_filter_and_sort(entity_type: str, random_filament: dict[str, Any]) -> None: + """Test filter and sort by a custom integer_range field for all entity types.""" + field_key = "test_int_range_field" + httpx.post( + f"{URL}/api/v1/field/{entity_type}/{field_key}", + json={"name": "Integer range field", "field_type": "integer_range"}, + ).raise_for_status() + # id1=[100,200], id2=[300,400] + id1 = _create_entity(entity_type, {field_key: json.dumps([100, 200])}, random_filament) + id2 = _create_entity(entity_type, {field_key: json.dumps([300, 400])}, random_filament) + try: + # Both bounds: stored_min>=100 AND stored_max<=200 matches only id1 + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "100:200"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids + + # Min only: stored_min>=200 matches only id2 (300>=200; 100<200) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "200:"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 not in ids + assert id2 in ids + + # Max only: stored_max<=300 matches only id1 (200<=300; 400>300) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": ":300"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids + + # Sort ascending by stored_min (100 before 300) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={"sort": f"extra.{field_key}:asc"}) + assert_httpx_success(result) + ordered = [item for item in result.json() if item["id"] in (id1, id2)] + assert len(ordered) == 2 + assert ordered[0]["id"] == id1 + assert ordered[1]["id"] == id2 + + # Sort descending + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={"sort": f"extra.{field_key}:desc"}) + assert_httpx_success(result) + ordered = [item for item in result.json() if item["id"] in (id1, id2)] + assert len(ordered) == 2 + assert ordered[0]["id"] == id2 + assert ordered[1]["id"] == id1 + finally: + httpx.delete(f"{URL}/api/v1/field/{entity_type}/{field_key}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id2}").raise_for_status() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) +async def test_float_range_filter_and_sort(entity_type: str, random_filament: dict[str, Any]) -> None: + """Test filter and sort by a custom float_range field for all entity types.""" + field_key = "test_float_range_field" + httpx.post( + f"{URL}/api/v1/field/{entity_type}/{field_key}", + json={"name": "Float range field", "field_type": "float_range"}, + ).raise_for_status() + # id1=[1.5,2.5], id2=[3.5,4.5] + id1 = _create_entity(entity_type, {field_key: json.dumps([1.5, 2.5])}, random_filament) + id2 = _create_entity(entity_type, {field_key: json.dumps([3.5, 4.5])}, random_filament) + try: + # Both bounds: stored_min>=1.5 AND stored_max<=2.5 matches only id1 + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "1.5:2.5"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids + + # Min only: stored_min>=2.5 matches only id2 (3.5>=2.5; 1.5<2.5) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "2.5:"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 not in ids + assert id2 in ids + + # Max only: stored_max<=3.5 matches only id1 (2.5<=3.5; 4.5>3.5) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": ":3.5"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids + + # Sort ascending by stored_min (1.5 before 3.5) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={"sort": f"extra.{field_key}:asc"}) + assert_httpx_success(result) + ordered = [item for item in result.json() if item["id"] in (id1, id2)] + assert len(ordered) == 2 + assert ordered[0]["id"] == id1 + assert ordered[1]["id"] == id2 + + # Sort descending + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={"sort": f"extra.{field_key}:desc"}) + assert_httpx_success(result) + ordered = [item for item in result.json() if item["id"] in (id1, id2)] + assert len(ordered) == 2 + assert ordered[0]["id"] == id2 + assert ordered[1]["id"] == id1 + finally: + httpx.delete(f"{URL}/api/v1/field/{entity_type}/{field_key}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id2}").raise_for_status() + + # --------------------------------------------------------------------------- # Spool - text # --------------------------------------------------------------------------- @@ -108,154 +376,6 @@ async def test_sort_by_custom_field(random_filament: dict[str, Any]): httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() -# --------------------------------------------------------------------------- -# Spool - integer -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_by_numeric_custom_field(random_filament: dict[str, Any]): - """Test filtering and sorting by a custom integer field.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/numeric_field", - json={"name": "Numeric field", "field_type": "integer"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"numeric_field": json.dumps(100)}}, - ) - assert_httpx_success(result) - spool_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"numeric_field": json.dumps(200)}}, - ) - assert_httpx_success(result) - spool_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.numeric_field": "100"}) - assert_httpx_success(result) - data = result.json() - assert len(data) == 1 - assert data[0]["id"] == spool_id1 - - # Range filter - min only: stored_value >= 150 matches only spool2 (200) - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.numeric_field": "150:"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 not in ids - assert spool_id2 in ids - - # Range filter - max only: stored_value <= 150 matches only spool1 (100) - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.numeric_field": ":150"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 in ids - assert spool_id2 not in ids - - # Range filter - min and max: 50 <= stored_value <= 150 matches only spool1 (100) - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.numeric_field": "50:150"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 in ids - assert spool_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.numeric_field:asc"}) - assert_httpx_success(result) - test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] - assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id1 # 100 first - assert test_spools[1]["id"] == spool_id2 # 200 second - - result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.numeric_field:desc"}) - assert_httpx_success(result) - test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] - assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id2 # 200 first - assert test_spools[1]["id"] == spool_id1 # 100 second - - httpx.delete(f"{URL}/api/v1/field/spool/numeric_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() - - -# --------------------------------------------------------------------------- -# Spool - float -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_and_sort_float_custom_field(random_filament: dict[str, Any]): - """Test filtering and sorting by a float custom field.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/float_field", - json={"name": "Float field", "field_type": "float"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"float_field": json.dumps(1.5)}}, - ) - assert_httpx_success(result) - spool_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"float_field": json.dumps(2.5)}}, - ) - assert_httpx_success(result) - spool_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_field": "1.5"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 in ids - assert spool_id2 not in ids - - # Range filter - min only: stored_value >= 2.0 matches only spool2 (2.5) - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_field": "2.0:"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 not in ids - assert spool_id2 in ids - - # Range filter - max only: stored_value <= 2.0 matches only spool1 (1.5) - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_field": ":2.0"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 in ids - assert spool_id2 not in ids - - # Range filter - both: 1.0 <= stored_value <= 2.0 matches only spool1 (1.5) - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_field": "1.0:2.0"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 in ids - assert spool_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.float_field:asc"}) - assert_httpx_success(result) - test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] - assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id1 - assert test_spools[1]["id"] == spool_id2 - - result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.float_field:desc"}) - assert_httpx_success(result) - test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] - assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id2 - assert test_spools[1]["id"] == spool_id1 - - httpx.delete(f"{URL}/api/v1/field/spool/float_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() - - # --------------------------------------------------------------------------- # Spool - boolean # --------------------------------------------------------------------------- @@ -505,146 +625,6 @@ async def test_filter_sort_datetime_spool(random_filament: dict[str, Any]): httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() -# --------------------------------------------------------------------------- -# Spool - integer_range -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_integer_range_spool(random_filament: dict[str, Any]): - """Test filtering and sorting by a custom integer_range field on spools.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/int_range_field", - json={"name": "Integer range field", "field_type": "integer_range"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"int_range_field": json.dumps([100, 200])}}, - ) - assert_httpx_success(result) - spool_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"int_range_field": json.dumps([300, 400])}}, - ) - assert_httpx_success(result) - spool_id2 = result.json()["id"] - - # Filter by exact min:max - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.int_range_field": "100:200"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 in ids - assert spool_id2 not in ids - - # Filter by min only: stored_min >= 200 matches only spool2 ([300,400]) - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.int_range_field": "200:"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 not in ids - assert spool_id2 in ids - - # Filter by max only: stored_max <= 300 matches only spool1 ([100,200]) - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.int_range_field": ":300"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 in ids - assert spool_id2 not in ids - - # Sort ascending by min (100 before 300) - result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.int_range_field:asc"}) - assert_httpx_success(result) - test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] - assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id1 - assert test_spools[1]["id"] == spool_id2 - - # Sort descending - result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.int_range_field:desc"}) - assert_httpx_success(result) - test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] - assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id2 - assert test_spools[1]["id"] == spool_id1 - - httpx.delete(f"{URL}/api/v1/field/spool/int_range_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() - - -# --------------------------------------------------------------------------- -# Spool - float_range -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_float_range_spool(random_filament: dict[str, Any]): - """Test filtering and sorting by a custom float_range field on spools.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/float_range_field", - json={"name": "Float range field", "field_type": "float_range"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"float_range_field": json.dumps([1.5, 2.5])}}, - ) - assert_httpx_success(result) - spool_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"float_range_field": json.dumps([3.5, 4.5])}}, - ) - assert_httpx_success(result) - spool_id2 = result.json()["id"] - - # Filter by exact min:max - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_range_field": "1.5:2.5"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 in ids - assert spool_id2 not in ids - - # Filter by min only: stored_min >= 2.5 matches only spool2 ([3.5,4.5]) - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_range_field": "2.5:"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 not in ids - assert spool_id2 in ids - - # Filter by max only: stored_max <= 3.5 matches only spool1 ([1.5,2.5]) - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_range_field": ":3.5"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 in ids - assert spool_id2 not in ids - - # Sort ascending by min (1.5 before 3.5) - result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.float_range_field:asc"}) - assert_httpx_success(result) - test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] - assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id1 - assert test_spools[1]["id"] == spool_id2 - - # Sort descending - result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.float_range_field:desc"}) - assert_httpx_success(result) - test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] - assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id2 - assert test_spools[1]["id"] == spool_id1 - - httpx.delete(f"{URL}/api/v1/field/spool/float_range_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() - - # --------------------------------------------------------------------------- # Spool - empty filter # --------------------------------------------------------------------------- @@ -753,152 +733,32 @@ async def test_filter_sort_filament_custom_field(random_filament: dict[str, Any] # --------------------------------------------------------------------------- -# Filament - integer +# Filament - boolean # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_filter_sort_filament_integer(random_filament: dict[str, Any]): - """Test filtering and sorting filaments by a custom integer field.""" +async def test_filter_sort_filament_boolean(random_filament: dict[str, Any]): + """Test filtering and sorting filaments by a custom boolean field.""" vendor_id = random_filament["vendor"]["id"] result = httpx.post( - f"{URL}/api/v1/field/filament/fil_int_field", - json={"name": "Filament integer field", "field_type": "integer"}, + f"{URL}/api/v1/field/filament/fil_bool_field", + json={"name": "Filament boolean field", "field_type": "boolean"}, ) assert_httpx_success(result) result = httpx.post( f"{URL}/api/v1/filament", - json={"vendor_id": vendor_id, "density": 1.24, "diameter": 1.75, "extra": {"fil_int_field": json.dumps(10)}}, + json={ + "vendor_id": vendor_id, + "density": 1.24, + "diameter": 1.75, + "extra": {"fil_bool_field": json.dumps(bool(1))}, + }, ) assert_httpx_success(result) - filament_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/filament", - json={"vendor_id": vendor_id, "density": 1.24, "diameter": 1.75, "extra": {"fil_int_field": json.dumps(20)}}, - ) - assert_httpx_success(result) - filament_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_int_field": "10"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert filament_id1 in ids - assert filament_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_int_field:asc"}) - assert_httpx_success(result) - test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] - assert len(test_filaments) == 2 - assert test_filaments[0]["id"] == filament_id1 - assert test_filaments[1]["id"] == filament_id2 - - result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_int_field:desc"}) - assert_httpx_success(result) - test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] - assert len(test_filaments) == 2 - assert test_filaments[0]["id"] == filament_id2 - assert test_filaments[1]["id"] == filament_id1 - - httpx.delete(f"{URL}/api/v1/field/filament/fil_int_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() - - -# --------------------------------------------------------------------------- -# Filament - float -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_filament_float(random_filament: dict[str, Any]): - """Test filtering and sorting filaments by a custom float field.""" - vendor_id = random_filament["vendor"]["id"] - - result = httpx.post( - f"{URL}/api/v1/field/filament/fil_float_field", - json={"name": "Filament float field", "field_type": "float"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"fil_float_field": json.dumps(1.1)}, - }, - ) - assert_httpx_success(result) - filament_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"fil_float_field": json.dumps(9.9)}, - }, - ) - assert_httpx_success(result) - filament_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_float_field": "1.1"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert filament_id1 in ids - assert filament_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_float_field:asc"}) - assert_httpx_success(result) - test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] - assert len(test_filaments) == 2 - assert test_filaments[0]["id"] == filament_id1 - assert test_filaments[1]["id"] == filament_id2 - - result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_float_field:desc"}) - assert_httpx_success(result) - test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] - assert len(test_filaments) == 2 - assert test_filaments[0]["id"] == filament_id2 - assert test_filaments[1]["id"] == filament_id1 - - httpx.delete(f"{URL}/api/v1/field/filament/fil_float_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() - - -# --------------------------------------------------------------------------- -# Filament - boolean -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_filament_boolean(random_filament: dict[str, Any]): - """Test filtering and sorting filaments by a custom boolean field.""" - vendor_id = random_filament["vendor"]["id"] - - result = httpx.post( - f"{URL}/api/v1/field/filament/fil_bool_field", - json={"name": "Filament boolean field", "field_type": "boolean"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"fil_bool_field": json.dumps(bool(1))}, - }, - ) - assert_httpx_success(result) - filament_id_true = result.json()["id"] + filament_id_true = result.json()["id"] result = httpx.post( f"{URL}/api/v1/filament", @@ -1121,129 +981,6 @@ async def test_filter_sort_filament_datetime(random_filament: dict[str, Any]): httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() -# --------------------------------------------------------------------------- -# Filament - integer_range -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_filament_integer_range(random_filament: dict[str, Any]): - """Test filtering and sorting filaments by a custom integer_range field.""" - vendor_id = random_filament["vendor"]["id"] - - result = httpx.post( - f"{URL}/api/v1/field/filament/fil_int_range", - json={"name": "Filament integer range", "field_type": "integer_range"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"fil_int_range": json.dumps([200, 220])}, - }, - ) - assert_httpx_success(result) - filament_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"fil_int_range": json.dumps([240, 260])}, - }, - ) - assert_httpx_success(result) - filament_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_int_range": "200:220"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert filament_id1 in ids - assert filament_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_int_range:asc"}) - assert_httpx_success(result) - test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] - assert len(test_filaments) == 2 - assert test_filaments[0]["id"] == filament_id1 - assert test_filaments[1]["id"] == filament_id2 - - result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_int_range:desc"}) - assert_httpx_success(result) - test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] - assert len(test_filaments) == 2 - assert test_filaments[0]["id"] == filament_id2 - assert test_filaments[1]["id"] == filament_id1 - - httpx.delete(f"{URL}/api/v1/field/filament/fil_int_range").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() - - -# --------------------------------------------------------------------------- -# Filament - float_range -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_filament_float_range(random_filament: dict[str, Any]): - """Test filtering and sorting filaments by a custom float_range field.""" - vendor_id = random_filament["vendor"]["id"] - - result = httpx.post( - f"{URL}/api/v1/field/filament/fil_float_range", - json={"name": "Filament float range", "field_type": "float_range"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"fil_float_range": json.dumps([0.5, 1.0])}, - }, - ) - assert_httpx_success(result) - filament_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"fil_float_range": json.dumps([5.0, 7.5])}, - }, - ) - assert_httpx_success(result) - filament_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_float_range": "0.5:1.0"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert filament_id1 in ids - assert filament_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_float_range:asc"}) - assert_httpx_success(result) - test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] - assert len(test_filaments) == 2 - assert test_filaments[0]["id"] == filament_id1 - assert test_filaments[1]["id"] == filament_id2 - - httpx.delete(f"{URL}/api/v1/field/filament/fil_float_range").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() - - # --------------------------------------------------------------------------- # Filament - empty filter # --------------------------------------------------------------------------- @@ -1350,105 +1087,6 @@ async def test_filter_sort_vendor_custom_field(): -# --------------------------------------------------------------------------- -# Vendor - integer -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_vendor_integer(): - """Test filtering and sorting vendors by a custom integer field.""" - result = httpx.post( - f"{URL}/api/v1/field/vendor/ven_int_field", - json={"name": "Vendor integer field", "field_type": "integer"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor A Int", "extra": {"ven_int_field": json.dumps(5)}}, - ) - assert_httpx_success(result) - vendor_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor B Int", "extra": {"ven_int_field": json.dumps(50)}}, - ) - assert_httpx_success(result) - vendor_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_int_field": "5"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert vendor_id1 in ids - assert vendor_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_int_field:asc"}) - assert_httpx_success(result) - test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] - assert len(test_vendors) == 2 - assert test_vendors[0]["id"] == vendor_id1 - assert test_vendors[1]["id"] == vendor_id2 - - result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_int_field:desc"}) - assert_httpx_success(result) - test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] - assert len(test_vendors) == 2 - assert test_vendors[0]["id"] == vendor_id2 - assert test_vendors[1]["id"] == vendor_id1 - - httpx.delete(f"{URL}/api/v1/field/vendor/ven_int_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() - - -# --------------------------------------------------------------------------- -# Vendor - float -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_vendor_float(): - """Test filtering and sorting vendors by a custom float field.""" - result = httpx.post( - f"{URL}/api/v1/field/vendor/ven_float_field", - json={"name": "Vendor float field", "field_type": "float"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor A Float", "extra": {"ven_float_field": json.dumps(0.1)}}, - ) - assert_httpx_success(result) - vendor_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor B Float", "extra": {"ven_float_field": json.dumps(9.9)}}, - ) - assert_httpx_success(result) - vendor_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_float_field": "0.1"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert vendor_id1 in ids - assert vendor_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_float_field:asc"}) - assert_httpx_success(result) - test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] - assert len(test_vendors) == 2 - assert test_vendors[0]["id"] == vendor_id1 - assert test_vendors[1]["id"] == vendor_id2 - - httpx.delete(f"{URL}/api/v1/field/vendor/ven_float_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() - - # --------------------------------------------------------------------------- # Vendor - boolean # --------------------------------------------------------------------------- @@ -1664,126 +1302,6 @@ async def test_filter_sort_vendor_datetime(): httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() -# --------------------------------------------------------------------------- -# Vendor - integer_range -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_vendor_integer_range(): - """Test filtering and sorting vendors by a custom integer_range field.""" - result = httpx.post( - f"{URL}/api/v1/field/vendor/ven_int_range", - json={"name": "Vendor integer range", "field_type": "integer_range"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor Range Low", "extra": {"ven_int_range": json.dumps([10, 20])}}, - ) - assert_httpx_success(result) - vendor_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor Range High", "extra": {"ven_int_range": json.dumps([90, 100])}}, - ) - assert_httpx_success(result) - vendor_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_int_range": "10:20"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert vendor_id1 in ids - assert vendor_id2 not in ids - - # Filter by min only: stored_min >= 50 matches only vendor2 ([90,100]) - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_int_range": "50:"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert vendor_id1 not in ids - assert vendor_id2 in ids - - # Filter by max only: stored_max <= 50 matches only vendor1 ([10,20]) - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_int_range": ":50"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert vendor_id1 in ids - assert vendor_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_int_range:asc"}) - assert_httpx_success(result) - test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] - assert len(test_vendors) == 2 - assert test_vendors[0]["id"] == vendor_id1 - assert test_vendors[1]["id"] == vendor_id2 - - result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_int_range:desc"}) - assert_httpx_success(result) - test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] - assert len(test_vendors) == 2 - assert test_vendors[0]["id"] == vendor_id2 - assert test_vendors[1]["id"] == vendor_id1 - - httpx.delete(f"{URL}/api/v1/field/vendor/ven_int_range").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() - - -# --------------------------------------------------------------------------- -# Vendor - float_range -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_vendor_float_range(): - """Test filtering and sorting vendors by a custom float_range field.""" - result = httpx.post( - f"{URL}/api/v1/field/vendor/ven_float_range", - json={"name": "Vendor float range", "field_type": "float_range"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor Range Small", "extra": {"ven_float_range": json.dumps([0.1, 0.5])}}, - ) - assert_httpx_success(result) - vendor_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor Range Large", "extra": {"ven_float_range": json.dumps([10.0, 20.0])}}, - ) - assert_httpx_success(result) - vendor_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_float_range": "0.1:0.5"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert vendor_id1 in ids - assert vendor_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_float_range:asc"}) - assert_httpx_success(result) - test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] - assert len(test_vendors) == 2 - assert test_vendors[0]["id"] == vendor_id1 - assert test_vendors[1]["id"] == vendor_id2 - - result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_float_range:desc"}) - assert_httpx_success(result) - test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] - assert len(test_vendors) == 2 - assert test_vendors[0]["id"] == vendor_id2 - assert test_vendors[1]["id"] == vendor_id1 - - httpx.delete(f"{URL}/api/v1/field/vendor/ven_float_range").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() - - # --------------------------------------------------------------------------- # Vendor - empty filter # --------------------------------------------------------------------------- From e7f57343d7ba0beaf1fe70bcf4a4232d5b932efb Mon Sep 17 00:00:00 2001 From: Dieter Blomme Date: Sun, 29 Mar 2026 18:24:48 +0200 Subject: [PATCH 13/16] Add datetime range filter and restore integer/float range filter support - Backend: add datetime range filter using '|' separator (ISO dates contain ':') - Backend: restore integer/float range filter support (lost during attribution rewrite) - Frontend: replace single DateTimePicker with From/To range pickers for datetime fields --- client/src/components/column.tsx | 51 ++++++++++++++------- spoolman/database/extra_field_query.py | 61 ++++++++++++++++++++++---- 2 files changed, 86 insertions(+), 26 deletions(-) diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index 4196ba9a7..d4efa4074 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -102,26 +102,43 @@ function NumberRangeFilterDropdown({ ); } -function DateTimeFilterDropdown({ setSelectedKeys, selectedKeys, confirm, clearFilters }: FilterDropdownProps) { +function DateTimeRangeFilterDropdown({ setSelectedKeys, selectedKeys, confirm, clearFilters }: FilterDropdownProps) { const current = selectedKeys[0] as string | undefined; - const value = current ? dayjs.utc(current) : null; + let fromVal: dayjs.Dayjs | null = null; + let toVal: dayjs.Dayjs | null = null; + if (current && current.includes("|")) { + const parts = current.split("|", 2); + fromVal = parts[0] ? dayjs.utc(parts[0]) : null; + toVal = parts[1] ? dayjs.utc(parts[1]) : null; + } + + const updateKeys = (from: dayjs.Dayjs | null, to: dayjs.Dayjs | null) => { + if (from === null && to === null) { + setSelectedKeys([]); + } else { + setSelectedKeys([`${from ? from.utc().toISOString() : ""}|${to ? to.utc().toISOString() : ""}`]); + } + }; return (
- { - if (date) { - setSelectedKeys([date.utc().toISOString()]); - } else { - setSelectedKeys([]); - } - }} - style={{ marginBottom: 8, display: "block" }} - /> - + + updateKeys(date, toVal)} + /> + updateKeys(fromVal, date)} + /> + + @@ -594,7 +611,7 @@ export function CustomFieldColumn(props: Omit= int(min_val_str)) + if max_val_str: + int_conditions.append(stored <= int(max_val_str)) + except (ValueError, TypeError) as exc: + raise ValueError(f"Invalid integer range filter value for '{field_key}': {parsed_value}") from exc + if not int_conditions: + raise ValueError(f"Invalid integer range filter value for '{field_key}': {parsed_value}") + field_condition = sqlalchemy.and_(*int_conditions) + else: + try: + field_condition = field_table.value == json.dumps(int(parsed_value)) + except ValueError as exc: + raise ValueError(f"Invalid integer filter value for '{field_key}': {parsed_value}") from exc elif field_type == ExtraFieldType.float: - try: - field_condition = field_table.value == json.dumps(float(parsed_value)) - except ValueError as exc: - raise ValueError(f"Invalid float filter value for '{field_key}': {parsed_value}") from exc + if ":" in parsed_value: + min_val_str, max_val_str = parsed_value.split(":", 1) + float_conditions = [] + try: + stored = sqlalchemy.cast(field_table.value, sqlalchemy.Float) + if min_val_str: + float_conditions.append(stored >= float(min_val_str)) + if max_val_str: + float_conditions.append(stored <= float(max_val_str)) + except (ValueError, TypeError) as exc: + raise ValueError(f"Invalid float range filter value for '{field_key}': {parsed_value}") from exc + if not float_conditions: + raise ValueError(f"Invalid float range filter value for '{field_key}': {parsed_value}") + field_condition = sqlalchemy.and_(*float_conditions) + else: + try: + field_condition = field_table.value == json.dumps(float(parsed_value)) + except ValueError as exc: + raise ValueError(f"Invalid float filter value for '{field_key}': {parsed_value}") from exc elif field_type == ExtraFieldType.boolean: field_condition = field_table.value == json.dumps(_parse_boolean_filter(parsed_value)) elif field_type == ExtraFieldType.choice: @@ -213,7 +243,20 @@ def add_where_clause_extra_field( # noqa: C901, PLR0912, PLR0915 else: field_condition = field_table.value == json.dumps(parsed_value) elif field_type == ExtraFieldType.datetime: - field_condition = field_table.value == json.dumps(parsed_value) + if "|" in parsed_value: + start_str, end_str = parsed_value.split("|", 1) + dt_conditions = [] + if start_str: + dt_conditions.append(field_table.value >= json.dumps(start_str)) + if end_str: + dt_conditions.append(field_table.value <= json.dumps(end_str)) + if not dt_conditions: + raise ValueError( + f"Invalid datetime range filter for '{field_key}': {parsed_value}. Expected '|'." + ) + field_condition = sqlalchemy.and_(*dt_conditions) + else: + field_condition = field_table.value == json.dumps(parsed_value) elif field_type in (ExtraFieldType.integer_range, ExtraFieldType.float_range): if ":" not in parsed_value: raise ValueError( From ddb42a7804c8799827dfec010e2ee92fa9d5fc4c Mon Sep 17 00:00:00 2001 From: Dieter Blomme Date: Sun, 29 Mar 2026 18:36:13 +0200 Subject: [PATCH 14/16] Use local timezone in filter picker; add range filter tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DateTimeRangeFilterDropdown was initializing pickers with dayjs.utc(), causing them to display and accept UTC times directly. The entry form shows local time and converts to UTC, so the filter was inconsistent: entering the same displayed time would produce a filter value 2h offset from what was stored (in UTC+2), making exact boundaries fail unexpectedly. Fix: initialize picker values with dayjs() (local mode) so the filter picker behaves the same as the entry form — shows local times and converts to UTC. Tests: replace 3 separate entity-specific datetime tests with one parametrized test covering spool/filament/vendor, and add range filter cases (start|, |end, start|end). --- client/src/components/column.tsx | 4 +- .../tests/fields/test_filter_sort.py | 242 +++++------------- 2 files changed, 68 insertions(+), 178 deletions(-) diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index d4efa4074..8f838b21d 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -108,8 +108,8 @@ function DateTimeRangeFilterDropdown({ setSelectedKeys, selectedKeys, confirm, c let toVal: dayjs.Dayjs | null = null; if (current && current.includes("|")) { const parts = current.split("|", 2); - fromVal = parts[0] ? dayjs.utc(parts[0]) : null; - toVal = parts[1] ? dayjs.utc(parts[1]) : null; + fromVal = parts[0] ? dayjs(parts[0]) : null; + toVal = parts[1] ? dayjs(parts[1]) : null; } const updateKeys = (from: dayjs.Dayjs | null, to: dayjs.Dayjs | null) => { diff --git a/tests_integration/tests/fields/test_filter_sort.py b/tests_integration/tests/fields/test_filter_sort.py index cacadf610..0d9c10814 100644 --- a/tests_integration/tests/fields/test_filter_sort.py +++ b/tests_integration/tests/fields/test_filter_sort.py @@ -276,6 +276,72 @@ async def test_float_range_filter_and_sort(entity_type: str, random_filament: di httpx.delete(f"{URL}/api/v1/{entity_type}/{id2}").raise_for_status() +@pytest.mark.asyncio +@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) +async def test_datetime_filter_and_sort(entity_type: str, random_filament: dict[str, Any]) -> None: + """Test filter and sort by a custom datetime field for all entity types.""" + field_key = "test_dt_field" + httpx.post( + f"{URL}/api/v1/field/{entity_type}/{field_key}", + json={"name": "Datetime field", "field_type": "datetime"}, + ).raise_for_status() + dt_early = "2023-01-01T00:00:00" + dt_late = "2024-06-15T12:30:00" + id1 = _create_entity(entity_type, {field_key: json.dumps(dt_early)}, random_filament) + id2 = _create_entity(entity_type, {field_key: json.dumps(dt_late)}, random_filament) + try: + # Exact match: only id1 + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": dt_early}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids + + # Range: start only (>= 2023-06-01 matches only id2=2024-06-15) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "2023-06-01T00:00:00|"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 not in ids + assert id2 in ids + + # Range: end only (<= 2023-06-01 matches only id1=2023-01-01) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "|2023-06-01T00:00:00"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids + + # Range: both bounds (2022-01-01 to 2023-06-01 matches only id1) + result = httpx.get( + f"{URL}/api/v1/{entity_type}", + params={f"extra.{field_key}": "2022-01-01T00:00:00|2023-06-01T00:00:00"}, + ) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids + + # Sort ascending (early before late) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={"sort": f"extra.{field_key}:asc"}) + assert_httpx_success(result) + ordered = [item for item in result.json() if item["id"] in (id1, id2)] + assert len(ordered) == 2 + assert ordered[0]["id"] == id1 + assert ordered[1]["id"] == id2 + + # Sort descending + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={"sort": f"extra.{field_key}:desc"}) + assert_httpx_success(result) + ordered = [item for item in result.json() if item["id"] in (id1, id2)] + assert len(ordered) == 2 + assert ordered[0]["id"] == id2 + assert ordered[1]["id"] == id1 + finally: + httpx.delete(f"{URL}/api/v1/field/{entity_type}/{field_key}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id2}").raise_for_status() + + # --------------------------------------------------------------------------- # Spool - text # --------------------------------------------------------------------------- @@ -566,65 +632,6 @@ async def test_filter_multi_choice_custom_field(random_filament: dict[str, Any]) httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() -# --------------------------------------------------------------------------- -# Spool - datetime -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_datetime_spool(random_filament: dict[str, Any]): - """Test filtering and sorting by a custom datetime field on spools.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/dt_field", - json={"name": "Datetime field", "field_type": "datetime"}, - ) - assert_httpx_success(result) - - dt_early = "2023-01-01T00:00:00" - dt_late = "2024-06-15T12:30:00" - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"dt_field": json.dumps(dt_early)}}, - ) - assert_httpx_success(result) - spool_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"dt_field": json.dumps(dt_late)}}, - ) - assert_httpx_success(result) - spool_id2 = result.json()["id"] - - # Exact-match filter - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.dt_field": dt_early}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 in ids - assert spool_id2 not in ids - - # Sort ascending: early before late (ISO 8601 sorts lexicographically) - result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.dt_field:asc"}) - assert_httpx_success(result) - test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] - assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id1 - assert test_spools[1]["id"] == spool_id2 - - # Sort descending - result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.dt_field:desc"}) - assert_httpx_success(result) - test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] - assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id2 - assert test_spools[1]["id"] == spool_id1 - - httpx.delete(f"{URL}/api/v1/field/spool/dt_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() - - # --------------------------------------------------------------------------- # Spool - empty filter # --------------------------------------------------------------------------- @@ -920,67 +927,6 @@ async def test_filter_filament_multi_choice(random_filament: dict[str, Any]): httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() -# --------------------------------------------------------------------------- -# Filament - datetime -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_filament_datetime(random_filament: dict[str, Any]): - """Test filtering and sorting filaments by a custom datetime field.""" - vendor_id = random_filament["vendor"]["id"] - - result = httpx.post( - f"{URL}/api/v1/field/filament/fil_dt_field", - json={"name": "Filament datetime field", "field_type": "datetime"}, - ) - assert_httpx_success(result) - - dt_early = "2022-03-01T09:00:00" - dt_late = "2025-09-15T18:00:00" - - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"fil_dt_field": json.dumps(dt_early)}, - }, - ) - assert_httpx_success(result) - filament_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"fil_dt_field": json.dumps(dt_late)}, - }, - ) - assert_httpx_success(result) - filament_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_dt_field": dt_early}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert filament_id1 in ids - assert filament_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_dt_field:asc"}) - assert_httpx_success(result) - test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] - assert len(test_filaments) == 2 - assert test_filaments[0]["id"] == filament_id1 - assert test_filaments[1]["id"] == filament_id2 - - httpx.delete(f"{URL}/api/v1/field/filament/fil_dt_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() - - # --------------------------------------------------------------------------- # Filament - empty filter # --------------------------------------------------------------------------- @@ -1246,62 +1192,6 @@ async def test_filter_vendor_multi_choice(): httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() -# --------------------------------------------------------------------------- -# Vendor - datetime -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_vendor_datetime(): - """Test filtering and sorting vendors by a custom datetime field.""" - result = httpx.post( - f"{URL}/api/v1/field/vendor/ven_dt_field", - json={"name": "Vendor datetime field", "field_type": "datetime"}, - ) - assert_httpx_success(result) - - dt_early = "2020-01-01T00:00:00" - dt_late = "2026-12-31T23:59:59" - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor Old", "extra": {"ven_dt_field": json.dumps(dt_early)}}, - ) - assert_httpx_success(result) - vendor_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor New", "extra": {"ven_dt_field": json.dumps(dt_late)}}, - ) - assert_httpx_success(result) - vendor_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_dt_field": dt_early}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert vendor_id1 in ids - assert vendor_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_dt_field:asc"}) - assert_httpx_success(result) - test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] - assert len(test_vendors) == 2 - assert test_vendors[0]["id"] == vendor_id1 - assert test_vendors[1]["id"] == vendor_id2 - - result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_dt_field:desc"}) - assert_httpx_success(result) - test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] - assert len(test_vendors) == 2 - assert test_vendors[0]["id"] == vendor_id2 - assert test_vendors[1]["id"] == vendor_id1 - - httpx.delete(f"{URL}/api/v1/field/vendor/ven_dt_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() - - # --------------------------------------------------------------------------- # Vendor - empty filter # --------------------------------------------------------------------------- From 8746cba3b7f6c1192ed6f22defb1f96384b834bf Mon Sep 17 00:00:00 2001 From: Dieter Blomme Date: Sun, 29 Mar 2026 18:43:33 +0200 Subject: [PATCH 15/16] Replace all entity-specific tests with parametrized tests Replace 16 separate spool/filament/vendor tests for text, boolean, single-choice, multi-choice, and empty-filter with 5 parametrized tests that run against all three entity types. Each test is now defined once and executed 3 times, eliminating copy-paste and ensuring consistent coverage across all entity types. Parametrize invalid-filter-value 400 tests across all entity types The non-numeric-value error uses "Invalid integer/float range filter value" while the missing-colon error uses "Invalid range filter value". Both contain "range filter value", so use that as the assertion substring. --- .../tests/fields/test_filter_sort.py | 1200 ++++------------- 1 file changed, 275 insertions(+), 925 deletions(-) diff --git a/tests_integration/tests/fields/test_filter_sort.py b/tests_integration/tests/fields/test_filter_sort.py index 0d9c10814..da8ba5268 100644 --- a/tests_integration/tests/fields/test_filter_sort.py +++ b/tests_integration/tests/fields/test_filter_sort.py @@ -276,6 +276,11 @@ async def test_float_range_filter_and_sort(entity_type: str, random_filament: di httpx.delete(f"{URL}/api/v1/{entity_type}/{id2}").raise_for_status() +# --------------------------------------------------------------------------- +# Date fields (all entity types) +# --------------------------------------------------------------------------- + + @pytest.mark.asyncio @pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) async def test_datetime_filter_and_sort(entity_type: str, random_filament: dict[str, Any]) -> None: @@ -343,996 +348,341 @@ async def test_datetime_filter_and_sort(entity_type: str, random_filament: dict[ # --------------------------------------------------------------------------- -# Spool - text +# Text / boolean / choice / empty (all entity types) # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_filter_by_custom_field(random_filament: dict[str, Any]): - """Add a custom text field.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/test_field", - json={ - "name": "Test field", - "field_type": "text", - "default_value": json.dumps("Hello World"), - }, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"test_field": json.dumps("test_value")}}, - ) - assert_httpx_success(result) - spool_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"test_field": json.dumps("other_value")}}, - ) - assert_httpx_success(result) - spool_id2 = result.json()["id"] - - # Substring filter - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.test_field": "test_value"}) - assert_httpx_success(result) - data = result.json() - assert len(data) == 1 - assert data[0]["id"] == spool_id1 - - # Exact-match filter (wrapped in double quotes) - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.test_field": '"test_value"'}) - assert_httpx_success(result) - data = result.json() - assert len(data) == 1 - assert data[0]["id"] == spool_id1 - - # Multi-value OR filter - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.test_field": "test_value,other_value"}) - assert_httpx_success(result) - data = result.json() - assert len(data) == 2 - assert {item["id"] for item in data} == {spool_id1, spool_id2} - - httpx.delete(f"{URL}/api/v1/field/spool/test_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() +@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) +async def test_text_filter_and_sort(entity_type: str, random_filament: dict[str, Any]) -> None: + """Test filter and sort by a custom text field for all entity types.""" + field_key = "test_text_field" + httpx.post( + f"{URL}/api/v1/field/{entity_type}/{field_key}", + json={"name": "Text field", "field_type": "text"}, + ).raise_for_status() + # id1="beta", id2="alpha" so ascending order is id2 before id1 + id1 = _create_entity(entity_type, {field_key: json.dumps("beta")}, random_filament) + id2 = _create_entity(entity_type, {field_key: json.dumps("alpha")}, random_filament) + try: + # Substring filter: "beta" matches only id1 + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "beta"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids + # Exact-match filter (double-quoted) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": '"beta"'}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids -@pytest.mark.asyncio -async def test_sort_by_custom_field(random_filament: dict[str, Any]): - """Add a custom text field.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/text_field", - json={"name": "Text field", "field_type": "text"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"text_field": json.dumps("B value")}}, - ) - assert_httpx_success(result) - spool_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"text_field": json.dumps("A value")}}, - ) - assert_httpx_success(result) - spool_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.text_field:asc"}) - assert_httpx_success(result) - test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] - assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id2 # "A value" first - assert test_spools[1]["id"] == spool_id1 # "B value" second - - result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.text_field:desc"}) - assert_httpx_success(result) - test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] - assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id1 # "B value" first - assert test_spools[1]["id"] == spool_id2 # "A value" second - - httpx.delete(f"{URL}/api/v1/field/spool/text_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() + # Multi-value OR: both + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "beta,alpha"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 in ids + # Sort ascending: alpha before beta + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={"sort": f"extra.{field_key}:asc"}) + assert_httpx_success(result) + ordered = [item for item in result.json() if item["id"] in (id1, id2)] + assert len(ordered) == 2 + assert ordered[0]["id"] == id2 # alpha first + assert ordered[1]["id"] == id1 # beta second -# --------------------------------------------------------------------------- -# Spool - boolean -# --------------------------------------------------------------------------- + # Sort descending + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={"sort": f"extra.{field_key}:desc"}) + assert_httpx_success(result) + ordered = [item for item in result.json() if item["id"] in (id1, id2)] + assert len(ordered) == 2 + assert ordered[0]["id"] == id1 # beta first + assert ordered[1]["id"] == id2 # alpha second + finally: + httpx.delete(f"{URL}/api/v1/field/{entity_type}/{field_key}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id2}").raise_for_status() @pytest.mark.asyncio -async def test_filter_by_boolean_custom_field(random_filament: dict[str, Any]): - """Test filtering and sorting by a custom boolean field.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/boolean_field", +@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) +async def test_boolean_filter_and_sort(entity_type: str, random_filament: dict[str, Any]) -> None: + """Test filter and sort by a custom boolean field for all entity types.""" + field_key = "test_bool_field" + httpx.post( + f"{URL}/api/v1/field/{entity_type}/{field_key}", json={"name": "Boolean field", "field_type": "boolean"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"boolean_field": json.dumps(bool(1))}}, - ) - assert_httpx_success(result) - spool_id_true = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"boolean_field": json.dumps(bool(0))}}, - ) - assert_httpx_success(result) - spool_id_false = result.json()["id"] - - # Filter true - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.boolean_field": "true"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id_true in ids - assert spool_id_false not in ids - - # Filter false - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.boolean_field": "false"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id_false in ids - assert spool_id_true not in ids - - # Sort ascending: false before true - result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.boolean_field:asc"}) - assert_httpx_success(result) - test_spools = [item for item in result.json() if item["id"] in (spool_id_true, spool_id_false)] - assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id_false - assert test_spools[1]["id"] == spool_id_true - - # Sort descending: true before false - result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.boolean_field:desc"}) - assert_httpx_success(result) - test_spools = [item for item in result.json() if item["id"] in (spool_id_true, spool_id_false)] - assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id_true - assert test_spools[1]["id"] == spool_id_false - - httpx.delete(f"{URL}/api/v1/field/spool/boolean_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id_true}").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id_false}").raise_for_status() + ).raise_for_status() + id_true = _create_entity(entity_type, {field_key: json.dumps(bool(1))}, random_filament) + id_false = _create_entity(entity_type, {field_key: json.dumps(bool(0))}, random_filament) + try: + # Filter true + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "true"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id_true in ids + assert id_false not in ids + # Filter false + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "false"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id_false in ids + assert id_true not in ids -# --------------------------------------------------------------------------- -# Spool - single-choice -# --------------------------------------------------------------------------- + # Sort ascending: false before true + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={"sort": f"extra.{field_key}:asc"}) + assert_httpx_success(result) + ordered = [item for item in result.json() if item["id"] in (id_true, id_false)] + assert len(ordered) == 2 + assert ordered[0]["id"] == id_false + assert ordered[1]["id"] == id_true + + # Sort descending: true before false + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={"sort": f"extra.{field_key}:desc"}) + assert_httpx_success(result) + ordered = [item for item in result.json() if item["id"] in (id_true, id_false)] + assert len(ordered) == 2 + assert ordered[0]["id"] == id_true + assert ordered[1]["id"] == id_false + finally: + httpx.delete(f"{URL}/api/v1/field/{entity_type}/{field_key}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id_true}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id_false}").raise_for_status() @pytest.mark.asyncio -async def test_filter_single_choice_custom_field(random_filament: dict[str, Any]): - """Test filtering and sorting by a single-choice custom field.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/choice_field", +@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) +async def test_single_choice_filter_and_sort(entity_type: str, random_filament: dict[str, Any]) -> None: + """Test filter and sort by a single-choice custom field for all entity types.""" + field_key = "test_choice_field" + httpx.post( + f"{URL}/api/v1/field/{entity_type}/{field_key}", json={ "name": "Choice field", "field_type": "choice", - "choices": ["OptionA", "OptionB", "OptionC"], + "choices": ["OptionA", "OptionB"], "multi_choice": False, }, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"choice_field": json.dumps("OptionA")}}, - ) - assert_httpx_success(result) - spool_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"choice_field": json.dumps("OptionB")}}, - ) - assert_httpx_success(result) - spool_id2 = result.json()["id"] - - # Filter by single value - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.choice_field": "OptionA"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 in ids - assert spool_id2 not in ids - - # Multi-value OR - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.choice_field": "OptionA,OptionB"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 in ids - assert spool_id2 in ids - - # Sort ascending: OptionA before OptionB - result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.choice_field:asc"}) - assert_httpx_success(result) - test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] - assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id1 - assert test_spools[1]["id"] == spool_id2 - - # Sort descending - result = httpx.get(f"{URL}/api/v1/spool", params={"sort": "extra.choice_field:desc"}) - assert_httpx_success(result) - test_spools = [item for item in result.json() if item["id"] in (spool_id1, spool_id2)] - assert len(test_spools) == 2 - assert test_spools[0]["id"] == spool_id2 - assert test_spools[1]["id"] == spool_id1 - - httpx.delete(f"{URL}/api/v1/field/spool/choice_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() + ).raise_for_status() + id1 = _create_entity(entity_type, {field_key: json.dumps("OptionA")}, random_filament) + id2 = _create_entity(entity_type, {field_key: json.dumps("OptionB")}, random_filament) + try: + # Single value: only id1 + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "OptionA"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids + + # Multi-value OR: both + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "OptionA,OptionB"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 in ids + # Sort ascending: OptionA before OptionB + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={"sort": f"extra.{field_key}:asc"}) + assert_httpx_success(result) + ordered = [item for item in result.json() if item["id"] in (id1, id2)] + assert len(ordered) == 2 + assert ordered[0]["id"] == id1 # OptionA first + assert ordered[1]["id"] == id2 # OptionB second -# --------------------------------------------------------------------------- -# Spool - multi-choice -# --------------------------------------------------------------------------- + # Sort descending + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={"sort": f"extra.{field_key}:desc"}) + assert_httpx_success(result) + ordered = [item for item in result.json() if item["id"] in (id1, id2)] + assert len(ordered) == 2 + assert ordered[0]["id"] == id2 # OptionB first + assert ordered[1]["id"] == id1 # OptionA second + finally: + httpx.delete(f"{URL}/api/v1/field/{entity_type}/{field_key}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id2}").raise_for_status() @pytest.mark.asyncio -async def test_filter_multi_choice_custom_field(random_filament: dict[str, Any]): - """Test filtering by a multi-choice custom field.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/multi_choice_field", +@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) +async def test_multi_choice_filter(entity_type: str, random_filament: dict[str, Any]) -> None: + """Test filter by a multi-choice custom field for all entity types.""" + field_key = "test_multi_field" + httpx.post( + f"{URL}/api/v1/field/{entity_type}/{field_key}", json={ "name": "Multi-choice field", "field_type": "choice", "choices": ["A", "B", "C"], "multi_choice": True, }, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"multi_choice_field": json.dumps(["A", "B"])}}, - ) - assert_httpx_success(result) - spool_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"multi_choice_field": json.dumps(["C"])}}, - ) - assert_httpx_success(result) - spool_id2 = result.json()["id"] - - # Filter by A - only spool 1 - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.multi_choice_field": "A"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 in ids - assert spool_id2 not in ids - - # Filter by C - only spool 2 - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.multi_choice_field": "C"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id2 in ids - assert spool_id1 not in ids - - # Multi-value OR: both - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.multi_choice_field": "A,C"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 in ids - assert spool_id2 in ids - - httpx.delete(f"{URL}/api/v1/field/spool/multi_choice_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() + ).raise_for_status() + # id1 has [A, B], id2 has [C] + id1 = _create_entity(entity_type, {field_key: json.dumps(["A", "B"])}, random_filament) + id2 = _create_entity(entity_type, {field_key: json.dumps(["C"])}, random_filament) + try: + # Filter by A: only id1 + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "A"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids + # Filter by C: only id2 + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "C"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id2 in ids + assert id1 not in ids -# --------------------------------------------------------------------------- -# Spool - empty filter -# --------------------------------------------------------------------------- + # Multi-value OR (A,C): both + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "A,C"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 in ids + finally: + httpx.delete(f"{URL}/api/v1/field/{entity_type}/{field_key}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id2}").raise_for_status() @pytest.mark.asyncio -async def test_filter_empty_custom_field(random_filament: dict[str, Any]): - """Test the empty-string filter returns items that have no value set for a custom field.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/optional_field", +@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) +async def test_empty_filter(entity_type: str, random_filament: dict[str, Any]) -> None: + """Test the empty-string filter returns only items with no value set for a custom field.""" + field_key = "test_optional_field" + httpx.post( + f"{URL}/api/v1/field/{entity_type}/{field_key}", json={"name": "Optional field", "field_type": "text"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"], "extra": {"optional_field": json.dumps("has_value")}}, - ) - assert_httpx_success(result) - spool_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/spool", - json={"filament_id": random_filament["id"]}, - ) - assert_httpx_success(result) - spool_id2 = result.json()["id"] - - # Empty filter - spool without field should appear - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.optional_field": ""}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id2 in ids - assert spool_id1 not in ids - - # Value filter - spool with field should appear - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.optional_field": "has_value"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert spool_id1 in ids - assert spool_id2 not in ids - - httpx.delete(f"{URL}/api/v1/field/spool/optional_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/spool/{spool_id2}").raise_for_status() - - -# --------------------------------------------------------------------------- -# Filament - text -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_filament_custom_field(random_filament: dict[str, Any]): - """Test filtering and sorting filaments by a custom text field.""" - vendor_id = random_filament["vendor"]["id"] - - result = httpx.post( - f"{URL}/api/v1/field/filament/filament_tag", - json={"name": "Filament tag", "field_type": "text"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/filament", - json={"vendor_id": vendor_id, "density": 1.24, "diameter": 1.75, "extra": {"filament_tag": json.dumps("beta")}}, - ) - assert_httpx_success(result) - filament_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"filament_tag": json.dumps("alpha")}, - }, - ) - assert_httpx_success(result) - filament_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/filament", params={"extra.filament_tag": "beta"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert filament_id1 in ids - assert filament_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.filament_tag:asc"}) - assert_httpx_success(result) - test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] - assert len(test_filaments) == 2 - assert test_filaments[0]["id"] == filament_id2 # alpha first - assert test_filaments[1]["id"] == filament_id1 # beta second - - result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.filament_tag:desc"}) - assert_httpx_success(result) - test_filaments = [item for item in result.json() if item["id"] in (filament_id1, filament_id2)] - assert len(test_filaments) == 2 - assert test_filaments[0]["id"] == filament_id1 # beta first - assert test_filaments[1]["id"] == filament_id2 # alpha second - - httpx.delete(f"{URL}/api/v1/field/filament/filament_tag").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() - - -# --------------------------------------------------------------------------- -# Filament - boolean -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_filament_boolean(random_filament: dict[str, Any]): - """Test filtering and sorting filaments by a custom boolean field.""" - vendor_id = random_filament["vendor"]["id"] - - result = httpx.post( - f"{URL}/api/v1/field/filament/fil_bool_field", - json={"name": "Filament boolean field", "field_type": "boolean"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"fil_bool_field": json.dumps(bool(1))}, - }, - ) - assert_httpx_success(result) - filament_id_true = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"fil_bool_field": json.dumps(bool(0))}, - }, - ) - assert_httpx_success(result) - filament_id_false = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_bool_field": "true"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert filament_id_true in ids - assert filament_id_false not in ids - - result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_bool_field": "false"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert filament_id_false in ids - assert filament_id_true not in ids - - result = httpx.get(f"{URL}/api/v1/filament", params={"sort": "extra.fil_bool_field:asc"}) - assert_httpx_success(result) - test_filaments = [item for item in result.json() if item["id"] in (filament_id_true, filament_id_false)] - assert len(test_filaments) == 2 - assert test_filaments[0]["id"] == filament_id_false - assert test_filaments[1]["id"] == filament_id_true - - httpx.delete(f"{URL}/api/v1/field/filament/fil_bool_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id_true}").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id_false}").raise_for_status() - - -# --------------------------------------------------------------------------- -# Filament - single-choice -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_filament_single_choice(random_filament: dict[str, Any]): - """Test filtering filaments by a single-choice custom field.""" - vendor_id = random_filament["vendor"]["id"] - - result = httpx.post( - f"{URL}/api/v1/field/filament/fil_choice_field", - json={ - "name": "Filament choice field", - "field_type": "choice", - "choices": ["PLA", "PETG", "ABS"], - "multi_choice": False, - }, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"fil_choice_field": json.dumps("PLA")}, - }, - ) - assert_httpx_success(result) - filament_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"fil_choice_field": json.dumps("PETG")}, - }, - ) - assert_httpx_success(result) - filament_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_choice_field": "PLA"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert filament_id1 in ids - assert filament_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_choice_field": "PLA,PETG"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert filament_id1 in ids - assert filament_id2 in ids - - httpx.delete(f"{URL}/api/v1/field/filament/fil_choice_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() - - -# --------------------------------------------------------------------------- -# Filament - multi-choice -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_filament_multi_choice(random_filament: dict[str, Any]): - """Test filtering filaments by a multi-choice custom field.""" - vendor_id = random_filament["vendor"]["id"] - - result = httpx.post( - f"{URL}/api/v1/field/filament/fil_multi_field", - json={ - "name": "Filament multi-choice", - "field_type": "choice", - "choices": ["X", "Y", "Z"], - "multi_choice": True, - }, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"fil_multi_field": json.dumps(["X", "Y"])}, - }, - ) - assert_httpx_success(result) - filament_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"fil_multi_field": json.dumps(["Z"])}, - }, - ) - assert_httpx_success(result) - filament_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_multi_field": "X"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert filament_id1 in ids - assert filament_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_multi_field": "Z"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert filament_id2 in ids - assert filament_id1 not in ids - - httpx.delete(f"{URL}/api/v1/field/filament/fil_multi_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() - - -# --------------------------------------------------------------------------- -# Filament - empty filter -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_empty_filament_custom_field(random_filament: dict[str, Any]): - """Test the empty-string filter for filaments returns items with no value set.""" - vendor_id = random_filament["vendor"]["id"] - - result = httpx.post( - f"{URL}/api/v1/field/filament/fil_optional", - json={"name": "Optional filament field", "field_type": "text"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": vendor_id, - "density": 1.24, - "diameter": 1.75, - "extra": {"fil_optional": json.dumps("set")}, - }, - ) - assert_httpx_success(result) - filament_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/filament", - json={"vendor_id": vendor_id, "density": 1.24, "diameter": 1.75}, - ) - assert_httpx_success(result) - filament_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_optional": ""}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert filament_id2 in ids - assert filament_id1 not in ids - - result = httpx.get(f"{URL}/api/v1/filament", params={"extra.fil_optional": "set"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert filament_id1 in ids - assert filament_id2 not in ids - - httpx.delete(f"{URL}/api/v1/field/filament/fil_optional").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_id2}").raise_for_status() - - -# --------------------------------------------------------------------------- -# Vendor - text -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_vendor_custom_field(): - """Test filtering and sorting vendors by a custom text field.""" - result = httpx.post( - f"{URL}/api/v1/field/vendor/vendor_tier", - json={"name": "Vendor tier", "field_type": "text"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor Gold", "extra": {"vendor_tier": json.dumps("gold")}}, - ) - assert_httpx_success(result) - vendor_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor Silver", "extra": {"vendor_tier": json.dumps("silver")}}, - ) - assert_httpx_success(result) - vendor_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.vendor_tier": "gold"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert vendor_id1 in ids - assert vendor_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.vendor_tier:asc"}) - assert_httpx_success(result) - test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] - assert len(test_vendors) == 2 - assert test_vendors[0]["id"] == vendor_id1 # gold first - assert test_vendors[1]["id"] == vendor_id2 # silver second - - result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.vendor_tier:desc"}) - assert_httpx_success(result) - test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] - assert len(test_vendors) == 2 - assert test_vendors[0]["id"] == vendor_id2 # silver first - assert test_vendors[1]["id"] == vendor_id1 # gold second - - httpx.delete(f"{URL}/api/v1/field/vendor/vendor_tier").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() - - - -# --------------------------------------------------------------------------- -# Vendor - boolean -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_vendor_boolean(): - """Test filtering and sorting vendors by a custom boolean field.""" - result = httpx.post( - f"{URL}/api/v1/field/vendor/ven_bool_field", - json={"name": "Vendor boolean field", "field_type": "boolean"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor Active", "extra": {"ven_bool_field": json.dumps(bool(1))}}, - ) - assert_httpx_success(result) - vendor_id_true = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor Inactive", "extra": {"ven_bool_field": json.dumps(bool(0))}}, - ) - assert_httpx_success(result) - vendor_id_false = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_bool_field": "true"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert vendor_id_true in ids - assert vendor_id_false not in ids - - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_bool_field": "false"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert vendor_id_false in ids - assert vendor_id_true not in ids - - result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_bool_field:asc"}) - assert_httpx_success(result) - test_vendors = [item for item in result.json() if item["id"] in (vendor_id_true, vendor_id_false)] - assert len(test_vendors) == 2 - assert test_vendors[0]["id"] == vendor_id_false - assert test_vendors[1]["id"] == vendor_id_true - - httpx.delete(f"{URL}/api/v1/field/vendor/ven_bool_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id_true}").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id_false}").raise_for_status() - - -# --------------------------------------------------------------------------- -# Vendor - single-choice -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_sort_vendor_single_choice(): - """Test filtering and sorting vendors by a single-choice custom field.""" - result = httpx.post( - f"{URL}/api/v1/field/vendor/ven_choice_field", - json={ - "name": "Vendor choice field", - "field_type": "choice", - "choices": ["Bronze", "Silver", "Gold"], - "multi_choice": False, - }, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor Bronze", "extra": {"ven_choice_field": json.dumps("Bronze")}}, - ) - assert_httpx_success(result) - vendor_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor Gold", "extra": {"ven_choice_field": json.dumps("Gold")}}, - ) - assert_httpx_success(result) - vendor_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_choice_field": "Bronze"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert vendor_id1 in ids - assert vendor_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_choice_field": "Bronze,Gold"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert vendor_id1 in ids - assert vendor_id2 in ids - - result = httpx.get(f"{URL}/api/v1/vendor", params={"sort": "extra.ven_choice_field:asc"}) - assert_httpx_success(result) - test_vendors = [item for item in result.json() if item["id"] in (vendor_id1, vendor_id2)] - assert len(test_vendors) == 2 - assert test_vendors[0]["id"] == vendor_id1 # Bronze first - assert test_vendors[1]["id"] == vendor_id2 # Gold second - - httpx.delete(f"{URL}/api/v1/field/vendor/ven_choice_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() - - -# --------------------------------------------------------------------------- -# Vendor - multi-choice -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_filter_vendor_multi_choice(): - """Test filtering vendors by a multi-choice custom field.""" - result = httpx.post( - f"{URL}/api/v1/field/vendor/ven_multi_field", - json={ - "name": "Vendor multi-choice", - "field_type": "choice", - "choices": ["EU", "US", "ASIA"], - "multi_choice": True, - }, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor EU+US", "extra": {"ven_multi_field": json.dumps(["EU", "US"])}}, - ) - assert_httpx_success(result) - vendor_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor ASIA", "extra": {"ven_multi_field": json.dumps(["ASIA"])}}, - ) - assert_httpx_success(result) - vendor_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_multi_field": "EU"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert vendor_id1 in ids - assert vendor_id2 not in ids - - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_multi_field": "ASIA"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert vendor_id2 in ids - assert vendor_id1 not in ids - - httpx.delete(f"{URL}/api/v1/field/vendor/ven_multi_field").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() - - -# --------------------------------------------------------------------------- -# Vendor - empty filter -# --------------------------------------------------------------------------- + ).raise_for_status() + id1 = _create_entity(entity_type, {field_key: json.dumps("has_value")}, random_filament) + id2 = _create_entity(entity_type, {}, random_filament) + try: + # Empty filter: only id2 (field not set) + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": ""}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id2 in ids + assert id1 not in ids + # Value filter: only id1 + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "has_value"}) + assert_httpx_success(result) + ids = {item["id"] for item in result.json()} + assert id1 in ids + assert id2 not in ids + finally: + httpx.delete(f"{URL}/api/v1/field/{entity_type}/{field_key}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id1}").raise_for_status() + httpx.delete(f"{URL}/api/v1/{entity_type}/{id2}").raise_for_status() -@pytest.mark.asyncio -async def test_filter_empty_vendor_custom_field(): - """Test the empty-string filter for vendors returns items with no value set.""" - result = httpx.post( - f"{URL}/api/v1/field/vendor/ven_optional", - json={"name": "Optional vendor field", "field_type": "text"}, - ) - assert_httpx_success(result) - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor With Value", "extra": {"ven_optional": json.dumps("present")}}, - ) - assert_httpx_success(result) - vendor_id1 = result.json()["id"] - - result = httpx.post( - f"{URL}/api/v1/vendor", - json={"name": "Vendor Without Value"}, - ) - assert_httpx_success(result) - vendor_id2 = result.json()["id"] - - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_optional": ""}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert vendor_id2 in ids - assert vendor_id1 not in ids - - result = httpx.get(f"{URL}/api/v1/vendor", params={"extra.ven_optional": "present"}) - assert_httpx_success(result) - ids = {item["id"] for item in result.json()} - assert vendor_id1 in ids - assert vendor_id2 not in ids - - httpx.delete(f"{URL}/api/v1/field/vendor/ven_optional").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id1}").raise_for_status() - httpx.delete(f"{URL}/api/v1/vendor/{vendor_id2}").raise_for_status() # --------------------------------------------------------------------------- -# Invalid filter values → 400 +# Invalid filter values → 400 (all entity types) # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_invalid_numeric_custom_field_filters_return_400(): - """Invalid numeric custom-field filters should fail explicitly instead of being ignored.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/numeric_field_400", - json={"name": "Numeric field", "field_type": "integer"}, - ) - assert_httpx_success(result) - - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.numeric_field_400": "abc"}) - assert result.status_code == 400 - assert "Invalid integer filter value" in result.json()["message"] - - httpx.delete(f"{URL}/api/v1/field/spool/numeric_field_400").raise_for_status() +@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) +async def test_invalid_integer_filter_returns_400(entity_type: str) -> None: + """Invalid integer custom-field filters should fail with a 400 error.""" + field_key = "int_field_400" + httpx.post( + f"{URL}/api/v1/field/{entity_type}/{field_key}", + json={"name": "Integer 400", "field_type": "integer"}, + ).raise_for_status() + try: + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "abc"}) + assert result.status_code == 400 + assert "Invalid integer filter value" in result.json()["message"] + finally: + httpx.delete(f"{URL}/api/v1/field/{entity_type}/{field_key}").raise_for_status() @pytest.mark.asyncio -async def test_invalid_boolean_custom_field_filters_return_400(): - """Invalid boolean custom-field filters should fail explicitly instead of being coerced.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/boolean_field_400", - json={"name": "Boolean field", "field_type": "boolean"}, - ) - assert_httpx_success(result) - - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.boolean_field_400": "maybe"}) - assert result.status_code == 400 - assert "Invalid boolean filter value" in result.json()["message"] - - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.boolean_field_400": "yes"}) - assert result.status_code == 400 - assert "Invalid boolean filter value" in result.json()["message"] +@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) +async def test_invalid_boolean_filter_returns_400(entity_type: str) -> None: + """Invalid boolean custom-field filters should fail with a 400 error.""" + field_key = "bool_field_400" + httpx.post( + f"{URL}/api/v1/field/{entity_type}/{field_key}", + json={"name": "Boolean 400", "field_type": "boolean"}, + ).raise_for_status() + try: + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "maybe"}) + assert result.status_code == 400 + assert "Invalid boolean filter value" in result.json()["message"] - httpx.delete(f"{URL}/api/v1/field/spool/boolean_field_400").raise_for_status() + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "yes"}) + assert result.status_code == 400 + assert "Invalid boolean filter value" in result.json()["message"] + finally: + httpx.delete(f"{URL}/api/v1/field/{entity_type}/{field_key}").raise_for_status() @pytest.mark.asyncio -async def test_invalid_float_custom_field_filter_returns_400(): +@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) +async def test_invalid_float_filter_returns_400(entity_type: str) -> None: """Invalid float custom-field filters should fail with a 400 error.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/float_field_400", - json={"name": "Float field 400", "field_type": "float"}, - ) - assert_httpx_success(result) - - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_field_400": "notafloat"}) - assert result.status_code == 400 - assert "Invalid float filter value" in result.json()["message"] - - httpx.delete(f"{URL}/api/v1/field/spool/float_field_400").raise_for_status() + field_key = "float_field_400" + httpx.post( + f"{URL}/api/v1/field/{entity_type}/{field_key}", + json={"name": "Float 400", "field_type": "float"}, + ).raise_for_status() + try: + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "notafloat"}) + assert result.status_code == 400 + assert "Invalid float filter value" in result.json()["message"] + finally: + httpx.delete(f"{URL}/api/v1/field/{entity_type}/{field_key}").raise_for_status() @pytest.mark.asyncio -async def test_invalid_integer_range_custom_field_filter_returns_400(): +@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) +async def test_invalid_integer_range_filter_returns_400(entity_type: str) -> None: """Invalid integer_range custom-field filters should fail with a 400 error.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/int_range_400", + field_key = "int_range_400" + httpx.post( + f"{URL}/api/v1/field/{entity_type}/{field_key}", json={"name": "Integer range 400", "field_type": "integer_range"}, - ) - assert_httpx_success(result) - - # Missing colon separator - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.int_range_400": "100"}) - assert result.status_code == 400 - assert "Invalid range filter value" in result.json()["message"] - - # Non-numeric min value - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.int_range_400": "abc:200"}) - assert result.status_code == 400 - assert "Invalid range filter value" in result.json()["message"] + ).raise_for_status() + try: + # Missing colon separator + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "100"}) + assert result.status_code == 400 + assert "Invalid range filter value" in result.json()["message"] - httpx.delete(f"{URL}/api/v1/field/spool/int_range_400").raise_for_status() + # Non-numeric min value + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "abc:200"}) + assert result.status_code == 400 + assert "range filter value" in result.json()["message"] + finally: + httpx.delete(f"{URL}/api/v1/field/{entity_type}/{field_key}").raise_for_status() @pytest.mark.asyncio -async def test_invalid_float_range_custom_field_filter_returns_400(): +@pytest.mark.parametrize("entity_type", ["spool", "filament", "vendor"]) +async def test_invalid_float_range_filter_returns_400(entity_type: str) -> None: """Invalid float_range custom-field filters should fail with a 400 error.""" - result = httpx.post( - f"{URL}/api/v1/field/spool/float_range_400", + field_key = "float_range_400" + httpx.post( + f"{URL}/api/v1/field/{entity_type}/{field_key}", json={"name": "Float range 400", "field_type": "float_range"}, - ) - assert_httpx_success(result) - - # Missing colon separator - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_range_400": "1.5"}) - assert result.status_code == 400 - assert "Invalid range filter value" in result.json()["message"] - - # Non-numeric min value - result = httpx.get(f"{URL}/api/v1/spool", params={"extra.float_range_400": "notanum:2.5"}) - assert result.status_code == 400 - assert "Invalid range filter value" in result.json()["message"] + ).raise_for_status() + try: + # Missing colon separator + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "1.5"}) + assert result.status_code == 400 + assert "Invalid range filter value" in result.json()["message"] - httpx.delete(f"{URL}/api/v1/field/spool/float_range_400").raise_for_status() + # Non-numeric min value + result = httpx.get(f"{URL}/api/v1/{entity_type}", params={f"extra.{field_key}": "notanum:2.5"}) + assert result.status_code == 400 + assert "range filter value" in result.json()["message"] + finally: + httpx.delete(f"{URL}/api/v1/field/{entity_type}/{field_key}").raise_for_status() From ebb6e2fb87d3a91343c4f2528c2ca99f4513b93c Mon Sep 17 00:00:00 2001 From: Dieter Blomme Date: Sun, 29 Mar 2026 18:54:32 +0200 Subject: [PATCH 16/16] Align custom filter dropdowns with Ant Design choice filter style All three custom filter dropdowns (text, number range, datetime range) now match the built-in choice filter footer: a border-top separator, Reset as a link button on the left (disabled when no filter is active), and OK as a primary button on the right. --- client/src/components/column.tsx | 75 +++++++++++++++++--------------- 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index 8f838b21d..7afec558e 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -28,28 +28,29 @@ const FilterDropdownLoading = () => { }; function TextFilterDropdown({ setSelectedKeys, selectedKeys, confirm, clearFilters }: FilterDropdownProps) { + const hasValue = selectedKeys.length > 0; return ( -
- setSelectedKeys(e.target.value ? [e.target.value] : [])} - onPressEnter={() => confirm()} - style={{ marginBottom: 8, display: "block" }} - /> - - - - -
+ +
+ ); } - function NumberRangeFilterDropdown({ setSelectedKeys, selectedKeys, @@ -74,9 +75,10 @@ function NumberRangeFilterDropdown({ } }; + const hasValue = selectedKeys.length > 0; return ( -
- + <> +
updateKeys(minVal, value)} /> - - - -
+
+ - -
+ +
+ ); } @@ -120,9 +122,10 @@ function DateTimeRangeFilterDropdown({ setSelectedKeys, selectedKeys, confirm, c } }; + const hasValue = selectedKeys.length > 0; return ( -
- + <> +
updateKeys(fromVal, date)} /> - - - -
+
+ - -
+ +
+ ); }