2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-13 18:45:40 +00:00

Param filters 2 (#9749)

* Filter updates

- Split code
- Allow multiple simultaneous filters against a given parameter
- Bug fixes

* Refactoring

* Cleanup

* fix for operator selection

* Backend fix

* Additional filtering options

* Updated documentation

* Impove filtering logic

* Tweak playwright tests

* Remove debug statements

* Tweak for login test
This commit is contained in:
Oliver
2025-06-08 22:03:50 +10:00
committed by GitHub
parent 026904b361
commit 6b261e122d
13 changed files with 392 additions and 181 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

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

View File

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

View File

@ -1358,8 +1358,9 @@ class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI):
- <value> 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(

View File

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

View File

@ -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<string>('=');
const clearFilterButton = useMemo(() => {
return (
<ActionIcon
aria-label={`clear-filter-${template.name}`}
variant='transparent'
color='red'
size='sm'
onClick={() => {
clearFilter(template.pk);
closeFilter();
}}
>
<IconCircleX />
</ActionIcon>
);
}, [clearFilter, template.pk]);
// Filter input element (depends on template type)
return useMemo(() => {
if (template.checkbox) {
setOperator('=');
return (
<Select
aria-label={`filter-${template.name}`}
data={[t`True`, t`False`]}
value={filterValue}
defaultValue={filterValue}
onChange={(val) => setFilter(template.pk, val ?? '', '')}
placeholder={t`Select a choice`}
rightSection={clearFilterButton}
/>
);
} else if (!!template.choices) {
setOperator('=');
return (
<Select
aria-label={`filter-${template.name}`}
data={template.choices
.split(',')
.map((choice: string) => 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 (
<Group gap='xs' align='left'>
<TextInput
onKeyDown={(event) => {
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={
<SegmentedControl
defaultValue='='
value={operator}
onChange={(value: string) => setOperator(value)}
size='xs'
data={['=', '<', '>']}
/>
}
/>
</Group>
);
}
}, [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<any>({});
/* 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<string, string> = {
'=': '',
'<': '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<string, string> = {};
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<number>(0);
const [selectedTemplate, setSelectedTemplate] = useState<number>(0);
const [selectedParameter, setSelectedParameter] = useState<number>(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 }) => (
<ParameterFilter
template={template}
filterValue={filterKey && parameterFilters[filterKey]}
setFilter={addParameterFilter}
clearFilter={clearParameterFilter}
closeFilter={close}
/>
)
filtering: Object.keys(filters).length > 0,
filter: ({ close }: { close: () => void }) => {
return (
<ParameterFilter
template={template}
filters={parameterFilters[`parameter_${template.pk}`] || {}}
setFilter={addParameterFilter}
clearFilter={clearParameterFilter}
closeFilter={close}
/>
);
}
};
});
}, [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);

View File

@ -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<string, string> = {
'=': '',
'>': '_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 (
<ActionIcon
aria-label={`clear-filter-${props.template.name}`}
variant='transparent'
color='red'
size='sm'
onClick={() => {
props.clearFilter(props.template.pk, operator ?? '');
props.closeFilter();
}}
>
<IconCircleX />
</ActionIcon>
);
}
/**
* 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 (
<Select
aria-label={`filter-${props.template.name}`}
data={[
{ value: 'true', label: t`True` },
{ value: 'false', label: t`False` }
]}
value={filterValue}
defaultValue={filterValue}
onChange={(val) => props.setFilter(props.template.pk, val ?? '', '=')}
placeholder={t`Select a choice`}
rightSection={<ClearFilterButton props={props} />}
/>
);
}
/*
* UI element for viewing and changing choice filter associated with a given parameter template.
* In this case, the template defines a set of choices that can be selected.
*/
function ChoiceParameterFilter(props: ParameterFilterProps) {
const filterValue = useMemo(() => {
return props.filters['='] ?? '';
}, [props.filters]);
return (
<Select
aria-label={`filter-${props.template.name}`}
data={props.template.choices
.split(',')
.map((choice: string) => choice.trim())}
value={filterValue}
defaultValue={filterValue}
onChange={(val) => props.setFilter(props.template.pk, val ?? '', '=')}
placeholder={t`Select a choice`}
searchable
rightSection={<ClearFilterButton props={props} />}
/>
);
}
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<string>(operator);
useEffect(() => {
setOp(operator);
}, [operator]);
return (
<Group gap='xs' wrap='nowrap'>
<div onMouseDown={(e) => e.stopPropagation()} style={{ width: 75 }}>
<Select
onClick={(event) => {
event?.stopPropagation();
}}
aria-label={`filter-${props.template.name}-operator`}
data={Object.keys(PARAMETER_FILTER_OPERATORS)}
value={op}
searchable={false}
clearable={false}
defaultValue={'='}
onChange={(value) => {
setOp(value ?? '=');
}}
size='sm'
disabled={readonly}
width={75}
/>
</div>
<TextInput
aria-label={`filter-${props.template.name}`}
placeholder={placeholder}
defaultValue={value}
onKeyDown={(event) => {
if (event.key === 'Enter') {
props.setFilter(
props.template.pk,
event.currentTarget.value || '',
op
);
props.closeFilter();
}
}}
rightSection={
readonly && <ClearFilterButton props={props} operator={op} />
}
/>
</Group>
);
}
/*
* In this case, the template is generic and does not have a specific type.
* Here, the user can apply multiple filter types (e.g., '=', '<', '>')
*/
function GenericParameterFilter(props: ParameterFilterProps) {
return (
<Stack gap='xs'>
{/* Render a row for each operator defined in the filters object */}
{Object.keys(props.filters).map((operator) => {
return (
<GenericFilterRow
props={props}
value={props.filters[operator] ?? ''}
operator={operator}
readonly
/>
);
})}
<Divider />
{/* Render an empty row for adding a new filter */}
<GenericFilterRow props={props} value='' operator='=' />
</Stack>
);
}
/**
* UI element for viewing and changing filter(s) associated with a given parameter template
* @param template - The parameter template object
* @param filters - The current filters applied to the table
* @param setFilter - Function to set a filter for the template
* @param clearFilter - Function to clear the filter for the template
* @param closeFilter - Function to close the filter UI
*/
export function ParameterFilter(props: ParameterFilterProps) {
// Filter input element (depends on template type)
return useMemo(() => {
if (props.template.checkbox) {
return <BooleanParameterFilter {...props} />;
} else if (!!props.template.choices) {
return <ChoiceParameterFilter {...props} />;
} else {
return <GenericParameterFilter {...props} />;
}
}, [props]);
}

View File

@ -238,7 +238,9 @@ test('Build Order - Allocation', async ({ browser }) => {
// Expand this row
await cell.click();
await page.getByRole('cell', { name: '2022-4-27', exact: true }).waitFor();
await page.getByRole('cell', { name: 'Reel Storage', exact: true }).waitFor();
await page
.getByRole('cell', { name: 'Electronics Lab/Reel Storage', exact: true })
.waitFor();
// Navigate to the "Incomplete Outputs" tab
await loadTab(page, 'Incomplete Outputs');

View File

@ -199,7 +199,9 @@ test('Parts - Allocations', async ({ browser }) => {
// Expand allocations against BO0001
await build_order_cell.click();
await page.getByRole('cell', { name: '# 3', exact: true }).waitFor();
await page.getByRole('cell', { name: 'Room 101', exact: true }).waitFor();
await page
.getByRole('cell', { name: 'Factory/Office Block/Room 101', exact: true })
.waitFor();
await build_order_cell.click();
// Check row options for BO0001

View File

@ -46,9 +46,12 @@ test('Login - Failures', async ({ page }) => {
test('Login - Change Password', async ({ page }) => {
await doLogin(page, 'noaccess', 'youshallnotpass');
await page.waitForLoadState('networkidle');
// Navigate to the 'change password' page
await navigate(page, 'settings/user/account');
await page.waitForLoadState('networkidle');
await page.getByLabel('action-menu-account-actions').click();
await page.getByLabel('action-menu-account-actions-change-password').click();