2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-14 11:05:41 +00:00

[Feature] Filter by parameter (#9739)

* Add shell task

* Filter parts by parameter value

* Allow more operation types

* Working on table filtering

* Filter improvements

* Update on enter key

* Improved query logic

* Enable filter for "generic" parameter types

* Placeholder text

* Documentation updates

* Fix typo

* Fix for boolean part parameter field

* Add API unit testings

* Cleanup

* add playwright tests
This commit is contained in:
Oliver
2025-06-06 15:06:11 +10:00
committed by GitHub
parent a63efc4089
commit 9138bad8bc
14 changed files with 533 additions and 14 deletions

View File

@ -12,7 +12,6 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
import build.admin
import build.serializers
import common.models
import part.models as part_models

View File

@ -1339,15 +1339,51 @@ class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI):
pass
queryset = self.filter_parametric_data(queryset)
queryset = self.order_by_parameter(queryset)
return queryset
def filter_parametric_data(self, queryset):
"""Filter queryset against part parameters.
Here we can perform a number of different functions:
Used to filter returned parts based on their parameter values.
To filter based on parameter value, supply query parameters like:
- parameter_<x>=<value>
- parameter_<x>_gt=<value>
- parameter_<x>_lte=<value>
where:
- <x> is the ID of the PartParameterTemplate.
- <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}))?$'
for param in self.request.query_params:
result = re.match(regex_pattern, param)
if not result:
continue
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(
queryset, template_id, value, func=operator
)
return queryset
def order_by_parameter(self, queryset):
"""Perform queryset ordering based on parameter value.
Ordering Based on Parameter Value:
- Used if the 'ordering' query param points to a parameter
- e.g. '&ordering=param_<id>' where <id> specifies the PartParameterTemplate
- Only parts which have a matching parameter are returned

View File

@ -9,11 +9,6 @@ Useful References:
- https://docs.djangoproject.com/en/4.0/ref/models/expressions/
- https://stackoverflow.com/questions/42543978/django-1-11-annotating-a-subquery-aggregate
Relevant PRs:
- https://github.com/inventree/InvenTree/pull/2797/
- https://github.com/inventree/InvenTree/pull/2827
"""
from decimal import Decimal
@ -38,6 +33,8 @@ from django.db.models.functions import Coalesce
from sql_util.utils import SubquerySum
import InvenTree.conversion
import InvenTree.helpers
import part.models
import stock.models
from build.status_codes import BuildStatusGroups
@ -343,7 +340,58 @@ def filter_by_parameter(queryset, template_id: int, value: str, func: str = ''):
Returns:
A queryset of Part objects filtered by the given parameter
"""
# TODO
try:
template = part.models.PartParameterTemplate.objects.get(pk=template_id)
except (ValueError, part.models.PartParameterTemplate.DoesNotExist):
# Return queryset unchanged if the template does not exist
return queryset
# Construct a "numeric" value
try:
value_numeric = float(value)
except (ValueError, TypeError):
value_numeric = None
if template.checkbox:
# Account for 'boolean' parameter values
# Convert to "True" or "False" string in this case
bool_value = InvenTree.helpers.str2bool(value)
value_numeric = 1 if bool_value else 0
value = str(bool_value)
# Boolean filtering is limited to exact matches
func = ''
elif value_numeric is None and template.units:
# Convert the raw value to the units of the template parameter
try:
value_numeric = InvenTree.conversion.convert_physical_value(
value, template.units
)
except Exception:
# The value cannot be converted - return an empty queryset
return queryset.none()
# Query for 'numeric' value - this has priority over 'string' value
q1 = Q(**{
'parameters__template': template,
'parameters__data_numeric__isnull': False,
f'parameters__data_numeric{func}': value_numeric,
})
# Query for 'string' value
q2 = Q(**{
'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()
else:
# If the value is not numeric, we only filter by the string value
queryset = queryset.filter(q2).distinct()
return queryset

View File

@ -445,3 +445,177 @@ class PartParameterTest(InvenTreeAPITestCase):
for idx, expected in expectation.items():
actual = get_param_value(response, template.pk, idx)
self.assertEqual(actual, expected)
class PartParameterFilterTest(InvenTreeAPITestCase):
"""Unit tests for filtering parts by parameter values."""
superuser = True
@classmethod
def setUpTestData(cls):
"""Setup test data for the filtering tests."""
super().setUpTestData()
cls.url = reverse('api-part-list')
# Create a number of part parameter templates
cls.template_length = PartParameterTemplate.objects.create(
name='Length', description='Length of the part', units='mm'
)
cls.template_width = PartParameterTemplate.objects.create(
name='Width', description='Width of the part', units='mm'
)
cls.template_ionized = PartParameterTemplate.objects.create(
name='Ionized', description='Is the part ionized?', checkbox=True
)
cls.template_color = PartParameterTemplate.objects.create(
name='Color', description='Color of the part', choices='red,green,blue'
)
cls.category = PartCategory.objects.create(
name='Test Category', description='A category for testing part parameters'
)
# Create a number of parts
parts = [
Part(
name=f'Part {i}',
description=f'Description for part {i}',
category=cls.category,
IPN=f'PART-{i:03d}',
level=0,
tree_id=0,
lft=0,
rght=0,
)
for i in range(1, 51)
]
Part.objects.bulk_create(parts)
# Create parameters for each part
parameters = []
for ii, part in enumerate(Part.objects.all()):
parameters.append(
PartParameter(
part=part,
template=cls.template_length,
data=(ii * 10) + 5, # Length in mm
data_numeric=(ii * 10) + 5, # Numeric value for length
)
)
parameters.append(
PartParameter(
part=part,
template=cls.template_width,
data=(50 - ii) * 5 + 2, # Width in mm
data_numeric=(50 - ii) * 5 + 2, # Width in mm
)
)
if ii < 25:
parameters.append(
PartParameter(
part=part,
template=cls.template_ionized,
data='true'
if ii % 5 == 0
else 'false', # Ionized every second part
data_numeric=1
if ii % 5 == 0
else 0, # Ionized every second part
)
)
if ii < 15:
parameters.append(
PartParameter(
part=part,
template=cls.template_color,
data=['red', 'green', 'blue'][ii % 3], # Cycle through colors
data_numeric=None, # No numeric value for color
)
)
# Bulk create all parameters
PartParameter.objects.bulk_create(parameters)
def test_filter_by_length(self):
"""Test basic filtering by length parameter."""
length_filters = [
('_lt', '25', 2),
('_lt', '25 mm', 2),
('_gt', '1 inch', 47),
('', '105', 1),
('_lt', '2 mile', 50),
]
for operator, value, expected_count in length_filters:
filter_name = f'parameter_{self.template_length.pk}' + operator
response = self.get(self.url, {filter_name: value}, expected_code=200).data
self.assertEqual(len(response), expected_count)
def test_filter_by_width(self):
"""Test basic filtering by width parameter."""
width_filters = [
('_lt', '102', 19),
('_lte', '102 mm', 20),
('_gte', '0.1 yards', 33),
('', '52mm', 1),
]
for operator, value, expected_count in width_filters:
filter_name = f'parameter_{self.template_width.pk}' + operator
response = self.get(self.url, {filter_name: value}, expected_code=200).data
self.assertEqual(len(response), expected_count)
def test_filter_by_ionized(self):
"""Test filtering by ionized parameter."""
ionized_filters = [
('', 'true', 5), # Ionized parts
('', 'false', 20), # Non-ionized parts
]
for operator, value, expected_count in ionized_filters:
filter_name = f'parameter_{self.template_ionized.pk}' + operator
response = self.get(self.url, {filter_name: value}, expected_code=200).data
self.assertEqual(len(response), expected_count)
def test_filter_by_color(self):
"""Test filtering by color parameter."""
for color in ['red', 'green', 'blue']:
response = self.get(
self.url,
{f'parameter_{self.template_color.pk}': color},
expected_code=200,
).data
self.assertEqual(len(response), 5)
def test_filter_multiple(self):
"""Test filtering by multiple parameters."""
data = {f'parameter_{self.template_length.pk}_lt': '225'}
response = self.get(self.url, data)
self.assertEqual(len(response.data), 22)
data[f'parameter_{self.template_width.pk}_gt'] = '150'
response = self.get(self.url, data)
self.assertEqual(len(response.data), 21)
data[f'parameter_{self.template_ionized.pk}'] = 'true'
response = self.get(self.url, data)
self.assertEqual(len(response.data), 5)
for color in ['red', 'green', 'blue']:
data[f'parameter_{self.template_color.pk}'] = color
response = self.get(self.url, data)
self.assertEqual(len(response.data), 1)

View File

@ -245,6 +245,7 @@ export function usePartParameterFields({
type: fieldType,
field_type: fieldType,
choices: fieldType === 'choice' ? choices : undefined,
default: fieldType === 'boolean' ? 'false' : undefined,
adjustValue: (value: any) => {
// Coerce boolean value into a string (required by backend)
return value.toString();

View File

@ -1,5 +1,11 @@
import { t } from '@lingui/core/macro';
import { Group } from '@mantine/core';
import {
ActionIcon,
Group,
SegmentedControl,
Select,
TextInput
} from '@mantine/core';
import { useHover } from '@mantine/hooks';
import { useQuery } from '@tanstack/react-query';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
@ -14,6 +20,7 @@ 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';
@ -90,6 +97,115 @@ 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<{
@ -115,6 +231,61 @@ export default function ParametricPartTable({
refetchOnMount: true
});
// Filters against selected part parameters
const [parameterFilters, setParameterFilters] = useState<any>({});
const clearParameterFilter = useCallback(
(templateId: number) => {
const filterName = `parameter_${templateId}`;
setParameterFilters((prev: any) => {
const newFilters = { ...prev };
Object.keys(newFilters).forEach((key: string) => {
// Remove any filters that match the template ID
if (key.startsWith(filterName)) {
delete newFilters[key];
}
});
return newFilters;
});
table.refreshTable();
},
[setParameterFilters, table.refreshTable]
);
const addParameterFilter = useCallback(
(templateId: number, value: string, operator: string) => {
// First, clear any existing filters for this template
clearParameterFilter(templateId);
// Map the operator to a more API-friendly format
const operations: Record<string, string> = {
'=': '',
'<': 'lt',
'>': 'gt',
'<=': 'lte',
'>=': 'gte'
};
const op = operations[operator] ?? '';
let filterName = `parameter_${templateId}`;
if (op) {
filterName += `_${op}`;
}
setParameterFilters((prev: any) => ({
...prev,
[filterName]: value?.trim() ?? ''
}));
table.refreshTable();
},
[setParameterFilters, clearParameterFilter, table.refreshTable]
);
const [selectedPart, setSelectedPart] = useState<number>(0);
const [selectedTemplate, setSelectedTemplate] = useState<number>(0);
const [selectedParameter, setSelectedParameter] = useState<number>(0);
@ -186,6 +357,10 @@ export default function ParametricPartTable({
title += ` [${template.units}]`;
}
const filterKey = Object.keys(parameterFilters).find((key: string) =>
key.startsWith(`parameter_${template.pk}`)
);
return {
accessor: `parameter_${template.pk}`,
title: title,
@ -199,10 +374,20 @@ export default function ParametricPartTable({
template={template}
canEdit={user.hasChangeRole(UserRoles.part)}
/>
),
filtering: !!filterKey,
filter: ({ close }: { close: () => void }) => (
<ParameterFilter
template={template}
filterValue={filterKey && parameterFilters[filterKey]}
setFilter={addParameterFilter}
clearFilter={clearParameterFilter}
closeFilter={close}
/>
)
};
});
}, [user, categoryParameters.data]);
}, [user, categoryParameters.data, parameterFilters]);
const onParameterClick = useCallback((template: number, part: any) => {
setSelectedTemplate(template);
@ -275,7 +460,8 @@ export default function ParametricPartTable({
category: categoryId,
cascade: true,
category_detail: true,
parameters: true
parameters: true,
...parameterFilters
},
onCellClick: ({ event, record, index, column, columnIndex }) => {
cancelEvent(event);

View File

@ -407,6 +407,41 @@ test('Parts - Parameters', async ({ browser }) => {
await page.getByRole('button', { name: 'Cancel' }).click();
});
test('Parts - Parameter Filtering', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/' });
await loadTab(page, 'Part Parameters');
await clearTableFilters(page);
// All parts should be available (no filters applied)
await page.getByText('/ 425').waitFor();
const clickOnParamFilter = async (name: string) => {
const button = await page
.getByRole('button', { name: `${name} Not sorted` })
.getByRole('button')
.first();
await button.scrollIntoViewIfNeeded();
await button.click();
};
const clearParamFilter = async (name: string) => {
await clickOnParamFilter(name);
await page.getByLabel(`clear-filter-${name}`).click();
};
// Let's filter by color
await clickOnParamFilter('Color');
await page.getByRole('option', { name: 'Red' }).click();
// Only 10 parts available
await page.getByText('/ 10').waitFor();
// Reset the filter
await clearParamFilter('Color');
await page.getByText('/ 425').waitFor();
});
test('Parts - Notes', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/69/notes' });