diff --git a/src/backend/InvenTree/common/filters.py b/src/backend/InvenTree/common/filters.py index d83f282d4c..6e6a00a58b 100644 --- a/src/backend/InvenTree/common/filters.py +++ b/src/backend/InvenTree/common/filters.py @@ -1,5 +1,14 @@ """Custom API filters for InvenTree.""" +import re + +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q +from django.db.models.query import QuerySet + +import InvenTree.conversion +import InvenTree.helpers + def filter_content_type( queryset, field_name: str, content_type: str | int | None, allow_null: bool = True @@ -18,9 +27,6 @@ def filter_content_type( if content_type is None: return queryset - from django.contrib.contenttypes.models import ContentType - from django.db.models import Q - ct = None # First, try to resolve the content type via a PK value @@ -51,3 +57,153 @@ def filter_content_type( q |= Q(**{f'{field_name}__isnull': True}) return queryset.filter(q) + + +"""A list of valid operators for filtering part parameters.""" +PARAMETER_FILTER_OPERATORS: list[str] = ['gt', 'gte', 'lt', 'lte', 'ne', 'icontains'] + + +def filter_parameters_by_value( + queryset: QuerySet, template_id: int, value: str, func: str = '' +) -> QuerySet: + """Filter the Parameter model based on the provided template and value. + + Arguments: + queryset: The initial QuerySet to filter. + template_id: The parameter template ID to filter by. + value: The value to filter against. + func: The filtering function to apply (e.g. 'gt', 'lt', etc). + + Returns: + A list of Parameter instances which match the given criteria. + + Notes: + - Parts which do not have a value for the given parameter are excluded. + """ + from common.models import ParameterTemplate + + # Ensure that the provided function is valid + if func and func not in PARAMETER_FILTER_OPERATORS: + raise ValueError(f'Invalid parameter filter function: {func}') + + # Ensure that the template exists + try: + template = ParameterTemplate.objects.get(pk=template_id) + except ParameterTemplate.DoesNotExist: + raise ValueError(f'Invalid parameter template ID: {template_id}') + + # Construct a "numeric" value for the filter + try: + value_numeric = float(value) + except (ValueError, TypeError): + value_numeric = None + + if template.checkbox: + # Account for 'boolean' parameter values + 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() + + # 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 + data_numeric = { + 'parameters_list__template': template, + 'parameters_list__data_numeric__isnull': False, + f'parameters_list__data_numeric{func}': value_numeric, + } + + query_numeric = Q(**data_numeric) + + # Query for 'string' value + data_text = { + 'parameters_list__template': template, + f'parameters_list__data{func}': str(value), + } + + if not text_only: + data_text['parameters_list__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 + + # queryset = Parameter.objects.prefetch_related('template').all() + + # 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: + return queryset.filter(q).distinct() + + +def filter_parametric_data(queryset: QuerySet, parameters: dict[str, str]) -> QuerySet: + """Filter the provided queryset by parametric data. + + Arguments: + queryset: The initial queryset to filter. + parameters: A dictionary of parameter filters to apply. + + Returns: + Filtered queryset. + + 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. + + Typically these filters would be provided against via an API request. + """ + queryset = queryset.prefetch_related('parameters_list', 'parameters_list__template') + + # Allowed lookup operations for parameter values + operators = '|'.join(PARAMETER_FILTER_OPERATORS) + + regex_pattern = rf'^parameter_(\d+)(_({operators}))?$' + + for param, value in parameters.items(): + result = re.match(regex_pattern, param) + if not result: + continue + + template_id = result.group(1) + operator = result.group(3) or '' + + queryset = filter_parameters_by_value( + queryset, template_id, value, func=operator + ) + + return queryset diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 50af274991..fbab4e3d96 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -1079,49 +1079,20 @@ class PartList( def filter_queryset(self, queryset): """Perform custom filtering of the queryset.""" + import common.filters + queryset = super().filter_queryset(queryset) - queryset = self.filter_parametric_data(queryset) + # Filter by parametric data + queryset = common.filters.filter_parametric_data( + queryset, self.request.query_params + ) + + # 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. - - 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 - 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) - - if not result: - continue - - template_id = result.group(1) - operator = result.group(3) or '' - - 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. diff --git a/src/backend/InvenTree/part/filters.py b/src/backend/InvenTree/part/filters.py index 33f3998ab6..294618d892 100644 --- a/src/backend/InvenTree/part/filters.py +++ b/src/backend/InvenTree/part/filters.py @@ -35,8 +35,6 @@ from django.db.models.query import QuerySet from sql_util.utils import SubquerySum -import InvenTree.conversion -import InvenTree.helpers import part.models import stock.models from build.status_codes import BuildStatusGroups @@ -521,106 +519,6 @@ def annotate_bom_item_can_build(queryset: QuerySet, reference: str = '') -> Quer return queryset -"""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: QuerySet, template_id: int, value: str, func: str = '' -) -> QuerySet: - """Filter the given queryset by a given template parameter. - - Parts which do not have a value for the given parameter are excluded. - - Arguments: - queryset: A queryset of Part objects - template_id (int): The ID of the template parameter to filter by - value (str): The value of the parameter to filter by - func (str): The function to use for the filter (e.g. __gt, __lt, __contains) - - 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): - # 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() - - # 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 - 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 - data_text = { - 'parameters__template': template, - f'parameters__data{func}': str(value), - } - - 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: - return queryset.filter(q).distinct() - - def order_by_parameter( queryset: QuerySet, template_id: int, ascending: bool = True ) -> QuerySet: