2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-12-16 09:18:10 +00:00

Implement generic "filtering" against queryset

This commit is contained in:
Oliver Walters
2025-11-25 02:44:34 +00:00
parent cb76020bf8
commit 7f82276df7
3 changed files with 167 additions and 142 deletions

View File

@@ -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_<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.
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

View File

@@ -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_<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
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.

View File

@@ -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: