2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-12 18:15:40 +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

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -7,7 +7,7 @@ title: Part Parameters
A part *parameter* describes a particular "attribute" or "property" of a specific part.
Part parameters are located in the "Parameters" tab, on each part detail page.
There is no limit for the number of part parameters and they are fully customizable through the use of [parameters templates](#parameter-templates).
There is no limit for the number of part parameters and they are fully customizable through the use of [parameter templates](#parameter-templates).
Here is an example of parameters for a capacitor:
@ -66,6 +66,37 @@ The parametric parts table allows the returned parts to be sorted by particular
{{ image("part/part_sort_by_param.png", "Sort by Parameter") }}
### Filtering by Parameter Value
The parametric parts table allows the returned parts to be filtered by particular parameter values. Click on the {{ icon("filter") }} button associated with the particular parameter, and enter the value you wish to filter against:
{{ image("part/filter_by_param.png", "Filter by Parameter") }}
The available filter options depend on the type of parameter being filtered. For example, a parameter with a limited set of choices will allow you to filter by those choices, while a numeric parameter will allow you to filter against a specific value and operator (e.g. greater than, less than, etc.).
#### Filtering by Multiple Parameters
Multiple parameters can be used to filter the parametric table. Simply add a new filter for each parameter you wish to filter against. The results will be filtered to include only parts which match *all* of the specified filters.
Each parameter column indicates whether a filter is currently applied:
{{ image("part/multiple_param_filters.png", "Multiple Parameter Filters") }}
#### 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.
For example, to show all parts with a *Resistance* parameter of greater than 10kΩ, you can enter `10k` or `10000` in the filter field, and the system will correctly interpret this as 10,000 ohms.
{{ image("part/filter_with_unit.png", "Unit Aware Filters") }}
#### Remove Filter
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") }}
## Parameter Units
The *units* field (which is defined against a [parameter template](#parameter-templates)) defines the base unit of that template. Any parameters which are created against that unit *must* be specified in compatible units.
@ -83,7 +114,7 @@ If a part parameter is created with a value which is incompatible with the units
This behaviour can be disabled if required, so that any parameter value is accepted.
### Parameter Sorting
### Parameter Unit Sorting
Parameter sorting takes unit conversion into account, meaning that values provided in different (but compatible) units are sorted correctly:

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' });

View File

@ -536,6 +536,14 @@ def setup_dev(c, tests=False):
# Setup / maintenance tasks
@task
def shell(c):
"""Launch a Django shell."""
manage(c, 'shell', pty=True)
@task
def superuser(c):
"""Create a superuser/admin account for the database."""
@ -1831,6 +1839,7 @@ development = Collection(
server,
setup_dev,
setup_test,
shell,
test,
test_translations,
translate,