diff --git a/docs/docs/assets/images/part/filter_by_param.png b/docs/docs/assets/images/part/filter_by_param.png new file mode 100644 index 0000000000..e591ae5736 Binary files /dev/null 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 new file mode 100644 index 0000000000..d055bdeae3 Binary files /dev/null and b/docs/docs/assets/images/part/filter_with_unit.png differ diff --git a/docs/docs/assets/images/part/multiple_param_filters.png b/docs/docs/assets/images/part/multiple_param_filters.png new file mode 100644 index 0000000000..a7fef7d5e4 Binary files /dev/null and b/docs/docs/assets/images/part/multiple_param_filters.png differ diff --git a/docs/docs/assets/images/part/part_sorting_units.png b/docs/docs/assets/images/part/part_sorting_units.png index 1564a9732a..9e354e3d7c 100644 Binary files a/docs/docs/assets/images/part/part_sorting_units.png and b/docs/docs/assets/images/part/part_sorting_units.png differ diff --git a/docs/docs/assets/images/part/remove_param_filter.png b/docs/docs/assets/images/part/remove_param_filter.png new file mode 100644 index 0000000000..5f9e4895e7 Binary files /dev/null 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 78e924ab49..7af764fa83 100644 --- a/docs/docs/part/parameter.md +++ b/docs/docs/part/parameter.md @@ -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: diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 1b017d73dc..b70abbe119 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -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 diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index f61f9da261..480ea7883a 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -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_= + - parameter__gt= + - parameter__lte= + + where: + - is the ID of the PartParameterTemplate. + - 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_' where specifies the PartParameterTemplate - Only parts which have a matching parameter are returned diff --git a/src/backend/InvenTree/part/filters.py b/src/backend/InvenTree/part/filters.py index 7b00aedf02..5a7ab187bc 100644 --- a/src/backend/InvenTree/part/filters.py +++ b/src/backend/InvenTree/part/filters.py @@ -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 diff --git a/src/backend/InvenTree/part/test_param.py b/src/backend/InvenTree/part/test_param.py index aa5e0f8344..4cf8965f00 100644 --- a/src/backend/InvenTree/part/test_param.py +++ b/src/backend/InvenTree/part/test_param.py @@ -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) diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index ab90a38b32..eec80a41bf 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -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(); diff --git a/src/frontend/src/tables/part/ParametricPartTable.tsx b/src/frontend/src/tables/part/ParametricPartTable.tsx index 4bd1bd987e..cdd2cafcae 100644 --- a/src/frontend/src/tables/part/ParametricPartTable.tsx +++ b/src/frontend/src/tables/part/ParametricPartTable.tsx @@ -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('='); + + 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<{ @@ -115,6 +231,61 @@ export default function ParametricPartTable({ refetchOnMount: true }); + // Filters against selected part parameters + const [parameterFilters, setParameterFilters] = useState({}); + + 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 = { + '=': '', + '<': '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(0); const [selectedTemplate, setSelectedTemplate] = useState(0); const [selectedParameter, setSelectedParameter] = useState(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 }) => ( + ) }; }); - }, [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); diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index d09351f5a7..4772031001 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -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' }); diff --git a/tasks.py b/tasks.py index 1d56232904..8a5c93092e 100644 --- a/tasks.py +++ b/tasks.py @@ -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,