diff --git a/docs/docs/assets/images/part/filter_by_param.png b/docs/docs/assets/images/part/filter_by_param.png index e591ae5736..a182b9236d 100644 Binary files a/docs/docs/assets/images/part/filter_by_param.png and b/docs/docs/assets/images/part/filter_by_param.png differ diff --git a/docs/docs/assets/images/part/filter_with_unit.png b/docs/docs/assets/images/part/filter_with_unit.png index d055bdeae3..bd60284435 100644 Binary files a/docs/docs/assets/images/part/filter_with_unit.png and b/docs/docs/assets/images/part/filter_with_unit.png differ diff --git a/docs/docs/assets/images/part/multiple_filters_same_param.png b/docs/docs/assets/images/part/multiple_filters_same_param.png new file mode 100644 index 0000000000..6299a6d709 Binary files /dev/null and b/docs/docs/assets/images/part/multiple_filters_same_param.png differ diff --git a/docs/docs/assets/images/part/remove_param_filter.png b/docs/docs/assets/images/part/remove_param_filter.png index 5f9e4895e7..e4f057e0dd 100644 Binary files a/docs/docs/assets/images/part/remove_param_filter.png and b/docs/docs/assets/images/part/remove_param_filter.png differ diff --git a/docs/docs/part/parameter.md b/docs/docs/part/parameter.md index 7af764fa83..4adff5e784 100644 --- a/docs/docs/part/parameter.md +++ b/docs/docs/part/parameter.md @@ -82,6 +82,12 @@ Each parameter column indicates whether a filter is currently applied: {{ image("part/multiple_param_filters.png", "Multiple Parameter Filters") }} +#### Multiple Filters Against the Same Parameter + +It is possible to apply multiple filters against the same parameter. For example, you can filter for parts with a *Resistance* parameter greater than 10kΩ and less than 100kΩ by adding two filters for the *Resistance* parameter: + +{{ image("part/multiple_filters_same_param.png", "Multiple Filters on Same Parameter") }} + #### Unit-Aware Filtering When filtering against a parameter which has a unit defined, you can specify the value in any compatible unit. The system will automatically convert the value to the base unit defined for that parameter template. @@ -90,12 +96,23 @@ For example, to show all parts with a *Resistance* parameter of greater than 10k {{ image("part/filter_with_unit.png", "Unit Aware Filters") }} -#### Remove Filter +#### Removing Filters To remove a filter against a given parameter, click on the {{ icon("circle-x", color='red') }} button associated with that filter: {{ image("part/remove_param_filter.png", "Remove Parameter Filter") }} +#### Available Filter Operators + +The following filter operators are available for parameter filtering: + +- `=`: Equal to +- `>`: Greater than +- `>=`: Greater than or equal to +- `<`: Less than +- `<=`: Less than or equal to +- `!=`: Not equal to +- `~`: Contains (for text parameters) ## Parameter Units diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index d4265fc19b..f34bd03952 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -15,7 +15,6 @@ from rest_framework.exceptions import PermissionDenied from taggit.serializers import TagListSerializerField import common.models as common_models -import common.settings import common.validators import generic.states.custom from importer.registry import register_importer @@ -142,8 +141,9 @@ class GlobalSettingsSerializer(SettingsSerializer): - It is overridden by an environment variable. """ - overrides = common.settings.global_setting_overrides() + from common.settings import global_setting_overrides + overrides = global_setting_overrides() return obj.key in overrides diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 480ea7883a..d86c3c17f4 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -1358,8 +1358,9 @@ class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI): - is the value to filter against. """ # Allowed lookup operations for parameter values - operations = '|'.join(['gt', 'lt', 'gte', 'lte']) - regex_pattern = rf'^parameter_(\d+)(_({operations}))?$' + operators = '|'.join(part.filters.PARAMETER_FILTER_OPERATORS) + + regex_pattern = rf'^parameter_(\d+)(_({operators}))?$' for param in self.request.query_params: result = re.match(regex_pattern, param) @@ -1370,9 +1371,6 @@ class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI): template_id = result.group(1) operator = result.group(3) or '' - if operator: - operator = '__' + operator - value = self.request.query_params.get(param, None) queryset = part.filters.filter_by_parameter( diff --git a/src/backend/InvenTree/part/filters.py b/src/backend/InvenTree/part/filters.py index 5a7ab187bc..420613d266 100644 --- a/src/backend/InvenTree/part/filters.py +++ b/src/backend/InvenTree/part/filters.py @@ -326,6 +326,10 @@ def annotate_sub_categories(): ) +"""A list of valid operators for filtering part parameters.""" +PARAMETER_FILTER_OPERATORS: list[str] = ['gt', 'gte', 'lt', 'lte', 'ne', 'icontains'] + + def filter_by_parameter(queryset, template_id: int, value: str, func: str = ''): """Filter the given queryset by a given template parameter. @@ -340,6 +344,9 @@ def filter_by_parameter(queryset, template_id: int, value: str, func: str = ''): Returns: A queryset of Part objects filtered by the given parameter """ + if func and func not in PARAMETER_FILTER_OPERATORS: + raise ValueError(f'Invalid parameter filter function supplied: {func}.') + try: template = part.models.PartParameterTemplate.objects.get(pk=template_id) except (ValueError, part.models.PartParameterTemplate.DoesNotExist): @@ -372,27 +379,49 @@ def filter_by_parameter(queryset, template_id: int, value: str, func: str = ''): # The value cannot be converted - return an empty queryset return queryset.none() + # Special handling for the "not equal" operator + if func == 'ne': + invert = True + func = '' + else: + invert = False + + # Some filters are only applicable to string values + text_only = any([func in ['icontains'], value_numeric is None]) + + # Ensure the function starts with a double underscore + if func and not func.startswith('__'): + func = f'__{func}' + # Query for 'numeric' value - this has priority over 'string' value - q1 = Q(**{ + data_numeric = { 'parameters__template': template, 'parameters__data_numeric__isnull': False, f'parameters__data_numeric{func}': value_numeric, - }) + } + + query_numeric = Q(**data_numeric) # Query for 'string' value - q2 = Q(**{ + data_text = { 'parameters__template': template, - 'parameters__data_numeric__isnull': True, f'parameters__data{func}': str(value), - }) + } - if value_numeric is not None: - queryset = queryset.filter(q1 | q2).distinct() + if not text_only: + data_text['parameters__data_numeric__isnull'] = True + + query_text = Q(**data_text) + + # Combine the queries based on whether we are filtering by text or numeric value + q = query_text if text_only else query_text | query_numeric + + # Special handling for the '__ne' (not equal) operator + # In this case, we want the *opposite* of the above queries + if invert: + return queryset.exclude(q).distinct() else: - # If the value is not numeric, we only filter by the string value - queryset = queryset.filter(q2).distinct() - - return queryset + return queryset.filter(q).distinct() def order_by_parameter(queryset, template_id: int, ascending=True): diff --git a/src/frontend/src/tables/part/ParametricPartTable.tsx b/src/frontend/src/tables/part/ParametricPartTable.tsx index cdd2cafcae..c5d1c150fa 100644 --- a/src/frontend/src/tables/part/ParametricPartTable.tsx +++ b/src/frontend/src/tables/part/ParametricPartTable.tsx @@ -1,11 +1,5 @@ import { t } from '@lingui/core/macro'; -import { - ActionIcon, - Group, - SegmentedControl, - Select, - TextInput -} from '@mantine/core'; +import { Group } from '@mantine/core'; import { useHover } from '@mantine/hooks'; import { useQuery } from '@tanstack/react-query'; import { type ReactNode, useCallback, useMemo, useState } from 'react'; @@ -20,7 +14,6 @@ import { getDetailUrl } from '@lib/functions/Navigation'; import { navigateToLink } from '@lib/functions/Navigation'; import type { TableFilter } from '@lib/types/Filters'; import type { ApiFormFieldSet } from '@lib/types/Forms'; -import { IconCircleX } from '@tabler/icons-react'; import { YesNoButton } from '../../components/buttons/YesNoButton'; import { useApi } from '../../contexts/ApiContext'; import { formatDecimal } from '../../defaults/formatters'; @@ -35,6 +28,10 @@ import type { TableColumn } from '../Column'; import { DescriptionColumn, PartColumn } from '../ColumnRenderers'; import { InvenTreeTable } from '../InvenTreeTable'; import { TableHoverCard } from '../TableHoverCard'; +import { + PARAMETER_FILTER_OPERATORS, + ParameterFilter +} from './ParametricPartTableFilters'; // Render an individual parameter cell function ParameterCell({ @@ -97,115 +94,6 @@ function ParameterCell({ ); } -function ParameterFilter({ - template, - filterValue, - setFilter, - clearFilter, - closeFilter -}: { - template: any; - filterValue?: string; - setFilter: (templateId: number, value: string, operator: string) => void; - clearFilter: (templateId: number) => void; - closeFilter: () => void; -}) { - const [operator, setOperator] = useState('='); - - const clearFilterButton = useMemo(() => { - return ( - { - clearFilter(template.pk); - closeFilter(); - }} - > - - - ); - }, [clearFilter, template.pk]); - - // Filter input element (depends on template type) - return useMemo(() => { - if (template.checkbox) { - setOperator('='); - return ( - choice.trim())} - value={filterValue} - defaultValue={filterValue} - onChange={(val) => setFilter(template.pk, val ?? '', '')} - placeholder={t`Select a choice`} - searchable - rightSection={clearFilterButton} - /> - ); - } else { - let placeholder: string = t`Enter a value`; - - if (template.units) { - placeholder += ` [${template.units}]`; - } - - return ( - - { - if (event.key === 'Enter') { - setFilter( - template.pk, - event.currentTarget.value || '', - operator - ); - closeFilter(); - } - }} - aria-label={`filter-${template.name}`} - placeholder={placeholder} - defaultValue={filterValue} - rightSection={clearFilterButton} - leftSectionWidth={75} - leftSectionProps={{ - style: { - paddingRight: '10px' - } - }} - leftSection={ - setOperator(value)} - size='xs' - data={['=', '<', '>']} - /> - } - /> - - ); - } - }, [template, filterValue, setFilter, clearFilterButton, operator]); -} - export default function ParametricPartTable({ categoryId }: Readonly<{ @@ -231,23 +119,63 @@ export default function ParametricPartTable({ refetchOnMount: true }); - // Filters against selected part parameters + /* Store filters against selected part parameters. + * These are stored in the format: + * { + * parameter_1: { + * '=': 'value1', + * '<': 'value2', + * ... + * }, + * parameter_2: { + * '=': 'value3', + * }, + * ... + * } + * + * Which allows multiple filters to be applied against each parameter template. + */ const [parameterFilters, setParameterFilters] = useState({}); + /* Remove filters for a specific parameter template + * - If no operator is specified, remove all filters for this template + * - If an operator is specified, remove filters for that operator only + */ const clearParameterFilter = useCallback( - (templateId: number) => { + (templateId: number, operator?: string) => { const filterName = `parameter_${templateId}`; - setParameterFilters((prev: any) => { - const newFilters = { ...prev }; - Object.keys(newFilters).forEach((key: string) => { + if (!operator) { + // If no operator is specified, remove all filters for this template + setParameterFilters((prev: any) => { + const newFilters = { ...prev }; // Remove any filters that match the template ID - if (key.startsWith(filterName)) { - delete newFilters[key]; - } + Object.keys(newFilters).forEach((key: string) => { + if (key == filterName) { + delete newFilters[key]; + } + }); + return newFilters; }); - return newFilters; + return; + } + + // An operator is specified, so we remove filters for that operator only + setParameterFilters((prev: any) => { + const filters = { ...prev }; + + const paramFilters = filters[filterName] || {}; + + if (paramFilters[operator]) { + // Remove the specific operator filter + delete paramFilters[operator]; + } + + return { + ...filters, + [filterName]: paramFilters + }; }); table.refreshTable(); @@ -255,37 +183,51 @@ export default function ParametricPartTable({ [setParameterFilters, table.refreshTable] ); + /** + * Add (or update) a filter for a specific parameter template. + * @param templateId - The ID of the parameter template to filter on. + * @param value - The value to filter by. + * @param operator - The operator to use for filtering (e.g., '=', '<', '>', etc.). + */ const addParameterFilter = useCallback( (templateId: number, value: string, operator: string) => { - // First, clear any existing filters for this template - clearParameterFilter(templateId); + const filterName = `parameter_${templateId}`; - // Map the operator to a more API-friendly format - const operations: Record = { - '=': '', - '<': 'lt', - '>': 'gt', - '<=': 'lte', - '>=': 'gte' - }; + setParameterFilters((prev: any) => { + const filters = { ...prev }; + const paramFilters = filters[filterName] || {}; - const op = operations[operator] ?? ''; - let filterName = `parameter_${templateId}`; + paramFilters[operator] = value; - if (op) { - filterName += `_${op}`; - } - - setParameterFilters((prev: any) => ({ - ...prev, - [filterName]: value?.trim() ?? '' - })); + return { + ...filters, + [filterName]: paramFilters + }; + }); table.refreshTable(); }, [setParameterFilters, clearParameterFilter, table.refreshTable] ); + // Construct the query filters for the table based on the parameter filters + const parametricQueryFilters = useMemo(() => { + const filters: Record = {}; + + Object.keys(parameterFilters).forEach((key: string) => { + const paramFilters: any = parameterFilters[key]; + + Object.keys(paramFilters).forEach((operator: string) => { + const name = `${key}${PARAMETER_FILTER_OPERATORS[operator] || ''}`; + const value = paramFilters[operator]; + + filters[name] = value; + }); + }); + + return filters; + }, [parameterFilters]); + const [selectedPart, setSelectedPart] = useState(0); const [selectedTemplate, setSelectedTemplate] = useState(0); const [selectedParameter, setSelectedParameter] = useState(0); @@ -357,9 +299,7 @@ export default function ParametricPartTable({ title += ` [${template.units}]`; } - const filterKey = Object.keys(parameterFilters).find((key: string) => - key.startsWith(`parameter_${template.pk}`) - ); + const filters = parameterFilters[`parameter_${template.pk}`] || {}; return { accessor: `parameter_${template.pk}`, @@ -375,16 +315,18 @@ export default function ParametricPartTable({ canEdit={user.hasChangeRole(UserRoles.part)} /> ), - filtering: !!filterKey, - filter: ({ close }: { close: () => void }) => ( - - ) + filtering: Object.keys(filters).length > 0, + filter: ({ close }: { close: () => void }) => { + return ( + + ); + } }; }); }, [user, categoryParameters.data, parameterFilters]); @@ -461,7 +403,7 @@ export default function ParametricPartTable({ cascade: true, category_detail: true, parameters: true, - ...parameterFilters + ...parametricQueryFilters }, onCellClick: ({ event, record, index, column, columnIndex }) => { cancelEvent(event); diff --git a/src/frontend/src/tables/part/ParametricPartTableFilters.tsx b/src/frontend/src/tables/part/ParametricPartTableFilters.tsx new file mode 100644 index 0000000000..0cc07e0eed --- /dev/null +++ b/src/frontend/src/tables/part/ParametricPartTableFilters.tsx @@ -0,0 +1,218 @@ +import { t } from '@lingui/core/macro'; +import { + ActionIcon, + Divider, + Group, + Select, + Stack, + TextInput +} from '@mantine/core'; +import { IconCircleX } from '@tabler/icons-react'; +import { useEffect, useMemo, useState } from 'react'; + +// Define set of allowed operators for parameter filters +export const PARAMETER_FILTER_OPERATORS: Record = { + '=': '', + '>': '_gt', + '>=': '_gte', + '<': '_lt', + '<=': '_lte', + '!=': '_ne', + '~': '_icontains' +}; + +type ParameterFilterProps = { + template: any; + filters: any; + setFilter: (templateId: number, value: string, operator: string) => void; + clearFilter: (templateId: number, operator?: string) => void; + closeFilter: () => void; +}; + +function ClearFilterButton({ + props, + operator +}: { + props: ParameterFilterProps; + operator?: string; +}) { + return ( + { + props.clearFilter(props.template.pk, operator ?? ''); + props.closeFilter(); + }} + > + + + ); +} + +/** + * UI element for viewing and changing boolean filter associated with a given parameter template + */ +function BooleanParameterFilter(props: ParameterFilterProps) { + const filterValue = useMemo(() => { + return props.filters['='] ?? ''; + }, [props.filters]); + + return ( + choice.trim())} + value={filterValue} + defaultValue={filterValue} + onChange={(val) => props.setFilter(props.template.pk, val ?? '', '=')} + placeholder={t`Select a choice`} + searchable + rightSection={} + /> + ); +} + +function GenericFilterRow({ + props, + value, + operator, + readonly +}: { + props: ParameterFilterProps; + value: string; + operator: string; + readonly?: boolean; +}) { + const placeholder: string = useMemo(() => { + let placeholder = t`Enter a value`; + + if (props.template.units) { + placeholder += ` [${props.template.units}]`; + } + + return placeholder; + }, [props.template.units]); + + const [op, setOp] = useState(operator); + + useEffect(() => { + setOp(operator); + }, [operator]); + + return ( + +
e.stopPropagation()} style={{ width: 75 }}> +