2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-01 03:00:54 +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)