mirror of
https://github.com/inventree/InvenTree.git
synced 2026-04-12 06:18:48 +00:00
[API] Enhanced filtering for generic parameters (#11383)
* Enhanced filtering for parameter templates - Allow filtering by model ID as well as model type * Enhanced filtering for ParameterTemplate - Required for the parameteric data tables - Enable filtering by base model ID - Enable filtering by related model ID * Bump API version * Remove outdated comments * Fix typo * Remove debug statement * Added unit tests * Playwright tests * Fix unit test * Bump requirements * Revert
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 454
|
||||
INVENTREE_API_VERSION = 455
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v455 -> 2026-02-19 : https://github.com/inventree/InvenTree/pull/11383
|
||||
- Adds "exists_for_model_id" filter to ParameterTemplate API endpoint
|
||||
- Adds "exists_for_related_model" filter to ParameterTemplate API endpoint
|
||||
- Adds "exists_for_related_model_id" filter to ParameterTemplate API endpoint
|
||||
|
||||
v454 -> 2026-02-19 : https://github.com/inventree/InvenTree/pull/11379
|
||||
- Adds "purchase_price" ordering option to StockItem API endpoint
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import json.decoder
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.exceptions import FieldDoesNotExist, ValidationError
|
||||
from django.db.models import Q
|
||||
from django.http import JsonResponse
|
||||
from django.http.response import HttpResponse
|
||||
@@ -35,6 +35,7 @@ import common.filters
|
||||
import common.models
|
||||
import common.serializers
|
||||
import InvenTree.conversion
|
||||
import InvenTree.models
|
||||
import InvenTree.ready
|
||||
from common.icons import get_icon_packs
|
||||
from common.settings import get_global_setting
|
||||
@@ -839,7 +840,13 @@ class ParameterTemplateFilter(FilterSet):
|
||||
content_type = common.filters.determine_content_type(value)
|
||||
|
||||
if not content_type:
|
||||
return queryset.none()
|
||||
raise ValidationError({
|
||||
'exists_for_model': 'Invalid model type provided - unable to determine content type'
|
||||
})
|
||||
|
||||
# If the 'filter_exists_for_model_id' filter is applied, defer to that
|
||||
if self.request.query_params.get('exists_for_model_id', None):
|
||||
return queryset
|
||||
|
||||
queryset = queryset.prefetch_related('parameters')
|
||||
|
||||
@@ -853,6 +860,160 @@ class ParameterTemplateFilter(FilterSet):
|
||||
# Return only those ParameterTemplates which have at least one Parameter for the given model type
|
||||
return queryset.filter(parameter_count__gt=0)
|
||||
|
||||
exists_for_model_id = rest_filters.NumberFilter(
|
||||
method='filter_exists_for_model_id', label='Exists For Model ID'
|
||||
)
|
||||
|
||||
def filter_exists_for_model_id(self, queryset, name, value):
|
||||
"""Filter queryset to include only ParameterTemplates which have at least one Parameter for the given model type and model id.
|
||||
|
||||
Notes:
|
||||
- This filter can only be applied if the 'exists_for_model' filter is also applied, as the model_id is only meaningful in the context of a particular model type.
|
||||
|
||||
Reference: https://github.com/inventree/InvenTree/issues/11381
|
||||
"""
|
||||
exists_for_model = self.request.query_params.get('exists_for_model', None)
|
||||
|
||||
if not exists_for_model:
|
||||
raise ValidationError({
|
||||
'exists_for_model': 'Invalid model type provided - unable to determine content type'
|
||||
})
|
||||
|
||||
content_type = common.filters.determine_content_type(exists_for_model)
|
||||
|
||||
if not content_type:
|
||||
raise ValidationError({
|
||||
'exists_for_model': 'Invalid model type provided - unable to determine content type'
|
||||
})
|
||||
|
||||
model_class = content_type.model_class()
|
||||
|
||||
# Try to find the model instance
|
||||
try:
|
||||
instance = model_class.objects.get(pk=value)
|
||||
except (model_class.DoesNotExist, ValueError):
|
||||
# If the model instance does not exist, then we can return an empty queryset
|
||||
raise ValidationError({
|
||||
'exists_for_model_id': 'Invalid model id provided - no such instance for the given model type'
|
||||
})
|
||||
|
||||
# If the provided model is a "tree" structure, then we should also include any child objects in the filter
|
||||
if isinstance(instance, InvenTree.models.InvenTreeTree):
|
||||
id_values = list(
|
||||
instance.get_descendants(include_self=True).values_list('pk', flat=True)
|
||||
)
|
||||
else:
|
||||
id_values = [instance.pk]
|
||||
|
||||
# Now, filter against model type and model id
|
||||
queryset = queryset.prefetch_related('parameters')
|
||||
|
||||
filters = {'model_type': content_type, 'model_id__in': id_values}
|
||||
|
||||
# Annotate the queryset to determine which ParameterTemplates have at least one Parameter defined
|
||||
queryset = queryset.annotate(
|
||||
parameter_count=SubqueryCount('parameters', filter=Q(**filters))
|
||||
)
|
||||
|
||||
return queryset.filter(parameter_count__gt=0)
|
||||
|
||||
exists_for_related_model = rest_filters.CharFilter(
|
||||
method='filter_exists_for_related_model', label='Exists For Related Model'
|
||||
)
|
||||
|
||||
def filter_exists_for_related_model(self, queryset, name, value):
|
||||
"""Filter applied to map parameter templates to a particular model relation against the target model.
|
||||
|
||||
For instance, specify 'category' to filter part parameters which exist for any part in that category.
|
||||
|
||||
Note:
|
||||
- This filter has no effect on its own
|
||||
- It requires the 'exists_for_model' filter to be applied (to specify the base model)
|
||||
- It requires the 'exists_for_related_model_id' filter to be applied also (to specify the related model id)
|
||||
"""
|
||||
return queryset
|
||||
|
||||
exists_for_related_model_id = rest_filters.NumberFilter(
|
||||
method='filter_exists_for_related_model_id', label='Exists For Model ID'
|
||||
)
|
||||
|
||||
def filter_exists_for_related_model_id(self, queryset, name, value):
|
||||
"""Filter queryset to include only ParameterTemplates which have at least one Parameter for the given related model type and model id.
|
||||
|
||||
Notes:
|
||||
- This filter can only be applied if the 'exists_for_model' filter is also applied, as the model_id is only meaningful in the context of a base model
|
||||
- This filter can only be applied if the 'exists_for_related_model' filter is also applied, as the related model id is only meaningful in the context of a particular model relation
|
||||
|
||||
Example: To filter part parameters which have at least one parameter defined for any part in category 5, you could apply the following filters:
|
||||
- exists_for_model=part
|
||||
- exists_for_related_model=category
|
||||
- exists_for_related_model_id=5
|
||||
"""
|
||||
model = self.request.query_params.get('exists_for_model', None)
|
||||
related_model = self.request.query_params.get('exists_for_related_model', None)
|
||||
|
||||
if not model or not related_model:
|
||||
raise ValidationError({
|
||||
'exists_for_model': 'Invalid model type provided - unable to determine content type'
|
||||
})
|
||||
|
||||
# Determine content type for the base model, to ensure they are valid
|
||||
model_type = common.filters.determine_content_type(model)
|
||||
|
||||
if not model_type:
|
||||
return queryset.none()
|
||||
|
||||
# Determine the model class for the 'related' model
|
||||
try:
|
||||
related_model_field = model_type.model_class()._meta.get_field(
|
||||
related_model
|
||||
)
|
||||
except FieldDoesNotExist:
|
||||
raise ValidationError({
|
||||
'exists_for_related_model': 'Invalid related model - no such field on the base model'
|
||||
})
|
||||
if related_model_field := model_type.model_class()._meta.get_field(
|
||||
related_model
|
||||
):
|
||||
related_model_class = related_model_field.related_model
|
||||
else:
|
||||
# Return an empty queryset if the provided related model is invalid
|
||||
return queryset.none()
|
||||
|
||||
# Find all instances of the related model which match the provided related model id
|
||||
try:
|
||||
related_instance = related_model_class.objects.get(pk=value)
|
||||
except (related_model_class.DoesNotExist, ValueError):
|
||||
return queryset.none()
|
||||
|
||||
# Account for potential tree structure in the related model
|
||||
if isinstance(related_instance, InvenTree.models.InvenTreeTree):
|
||||
related_instances = list(
|
||||
related_instance.get_descendants(include_self=True).values_list(
|
||||
'pk', flat=True
|
||||
)
|
||||
)
|
||||
else:
|
||||
related_instances = [related_instance.pk]
|
||||
|
||||
# Next, find all instances of the base model which are related to the related model instances
|
||||
model_instances = model_type.model_class().objects.filter(**{
|
||||
f'{related_model}__in': related_instances
|
||||
})
|
||||
model_instance_ids = list(model_instances.values_list('pk', flat=True))
|
||||
|
||||
# Now, filter against model type and model id
|
||||
queryset = queryset.prefetch_related('parameters')
|
||||
|
||||
filters = {'model_type': model_type, 'model_id__in': model_instance_ids}
|
||||
|
||||
# Annotate the queryset to determine which ParameterTemplates have at least one Parameter defined
|
||||
queryset = queryset.annotate(
|
||||
parameter_count=SubqueryCount('parameters', filter=Q(**filters))
|
||||
)
|
||||
|
||||
return queryset.filter(parameter_count__gt=0)
|
||||
|
||||
|
||||
class ParameterTemplateMixin:
|
||||
"""Mixin class for ParameterTemplate views."""
|
||||
|
||||
@@ -237,6 +237,217 @@ class ParameterAPITests(InvenTreeAPITestCase):
|
||||
f'Incorrect number of templates for model "{model_name}"',
|
||||
)
|
||||
|
||||
def test_template_extended_filters(self):
|
||||
"""Unit testing for more complex filters on the ParameterTemplate endpoint.
|
||||
|
||||
Ref: https://github.com/inventree/InvenTree/pull/11383
|
||||
|
||||
In these tests we will filter by complex model relations.
|
||||
"""
|
||||
from part.models import Part, PartCategory
|
||||
|
||||
# Create some part categories
|
||||
cat_mech = PartCategory.objects.create(
|
||||
name='Mechanical', description='Mechanical components'
|
||||
)
|
||||
cat_elec = PartCategory.objects.create(
|
||||
name='Electronics', description='Electronic components'
|
||||
)
|
||||
cat_pass = PartCategory.objects.create(
|
||||
name='Passive', description='Passive electronic components', parent=cat_elec
|
||||
)
|
||||
cat_res = PartCategory.objects.create(
|
||||
name='Resistors', description='Resistor components', parent=cat_pass
|
||||
)
|
||||
cat_cap = PartCategory.objects.create(
|
||||
name='Capacitors', description='Capacitor components', parent=cat_pass
|
||||
)
|
||||
|
||||
# Create some parts
|
||||
capacitors = [
|
||||
Part.objects.create(
|
||||
name=f'Capacitor {ii}', description='A capacitor', category=cat_cap
|
||||
)
|
||||
for ii in range(5)
|
||||
]
|
||||
|
||||
resistors = [
|
||||
Part.objects.create(
|
||||
name=f'Resistor {ii}', description='A resistor', category=cat_res
|
||||
)
|
||||
for ii in range(5)
|
||||
]
|
||||
|
||||
# Create some ParameterTemplates which relate to the category of the part
|
||||
resistance = common.models.ParameterTemplate.objects.create(
|
||||
name='Resistance', description='The resistance of a part', units='Ohms'
|
||||
)
|
||||
|
||||
capacitance = common.models.ParameterTemplate.objects.create(
|
||||
name='Capacitance', description='The capacitance of a part', units='Farads'
|
||||
)
|
||||
|
||||
tolerance = common.models.ParameterTemplate.objects.create(
|
||||
name='Tolerance', description='The tolerance of a part', units='%'
|
||||
)
|
||||
|
||||
for idx, resistor in enumerate(resistors):
|
||||
common.models.Parameter.objects.create(
|
||||
template=resistance,
|
||||
model_type=resistor.get_content_type(),
|
||||
model_id=resistor.pk,
|
||||
data=f'{10 * (idx + 1)}k',
|
||||
)
|
||||
|
||||
common.models.Parameter.objects.create(
|
||||
template=tolerance,
|
||||
model_type=resistor.get_content_type(),
|
||||
model_id=resistor.pk,
|
||||
data=f'{idx + 1}%',
|
||||
)
|
||||
|
||||
for idx, capacitor in enumerate(capacitors):
|
||||
common.models.Parameter.objects.create(
|
||||
template=capacitance,
|
||||
model_type=capacitor.get_content_type(),
|
||||
model_id=capacitor.pk,
|
||||
data=f'{10 * (idx + 1)}uF',
|
||||
)
|
||||
|
||||
common.models.Parameter.objects.create(
|
||||
template=tolerance,
|
||||
model_type=capacitor.get_content_type(),
|
||||
model_id=capacitor.pk,
|
||||
data=f'{5 * (idx + 1)}%',
|
||||
)
|
||||
|
||||
# Ensure that we have the expected number of templates and parameters created for testing
|
||||
self.assertEqual(common.models.ParameterTemplate.objects.count(), 3)
|
||||
self.assertEqual(common.models.Parameter.objects.count(), 20)
|
||||
|
||||
# Now, we have some data - let's apply some filtering
|
||||
url = reverse('api-parameter-template-list')
|
||||
|
||||
# Return *all* results, without filters
|
||||
response = self.get(url)
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
# Filter by 'exists_for_model'
|
||||
for model_name, count in {
|
||||
'part.part': 3,
|
||||
'part': 3,
|
||||
'company': 0,
|
||||
'build': 0,
|
||||
}.items():
|
||||
response = self.get(url, data={'exists_for_model': model_name})
|
||||
n = len(response.data)
|
||||
self.assertEqual(
|
||||
n,
|
||||
count,
|
||||
f'Incorrect number of templates ({n}) for model "{model_name}"',
|
||||
)
|
||||
|
||||
# Filter by 'exists_for_model' and 'exists_for_model_id'
|
||||
res = resistors[0]
|
||||
response = self.get(
|
||||
url, data={'exists_for_model': 'part.part', 'exists_for_model_id': res.pk}
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 2)
|
||||
pk_list = [t['pk'] for t in response.data]
|
||||
self.assertIn(resistance.pk, pk_list)
|
||||
self.assertIn(tolerance.pk, pk_list)
|
||||
|
||||
cap = capacitors[0]
|
||||
response = self.get(
|
||||
url, data={'exists_for_model': 'part.part', 'exists_for_model_id': cap.pk}
|
||||
)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
pk_list = [t['pk'] for t in response.data]
|
||||
self.assertIn(capacitance.pk, pk_list)
|
||||
self.assertIn(tolerance.pk, pk_list)
|
||||
|
||||
# Filter by 'exists_for_related_model' (test the "capacitor" relationship)
|
||||
|
||||
# Check the 'capacitor' category
|
||||
response = self.get(
|
||||
url,
|
||||
data={
|
||||
'exists_for_model': 'part.part',
|
||||
'exists_for_related_model': 'category',
|
||||
'exists_for_related_model_id': cat_cap.pk,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 2)
|
||||
pk_list = [t['pk'] for t in response.data]
|
||||
self.assertIn(capacitance.pk, pk_list)
|
||||
self.assertIn(tolerance.pk, pk_list)
|
||||
|
||||
# Check the 'electronics' category - this should return all parameters
|
||||
response = self.get(
|
||||
url,
|
||||
data={
|
||||
'exists_for_model': 'part.part',
|
||||
'exists_for_related_model': 'category',
|
||||
'exists_for_related_model_id': cat_elec.pk,
|
||||
},
|
||||
)
|
||||
self.assertEqual(len(response.data), 3)
|
||||
pk_list = [t['pk'] for t in response.data]
|
||||
self.assertIn(resistance.pk, pk_list)
|
||||
self.assertIn(capacitance.pk, pk_list)
|
||||
self.assertIn(tolerance.pk, pk_list)
|
||||
|
||||
# Check the 'mechanical' category - this should return no parameters
|
||||
response = self.get(
|
||||
url,
|
||||
data={
|
||||
'exists_for_model': 'part.part',
|
||||
'exists_for_related_model': 'category',
|
||||
'exists_for_related_model_id': cat_mech.pk,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
def test_invalid_filters(self):
|
||||
"""Test error messages for invalid filter combinations."""
|
||||
url = reverse('api-parameter-template-list')
|
||||
|
||||
# Invalid 'exists_for_model' value
|
||||
response = self.get(
|
||||
url, {'exists_for_model': 'asdf---invalid---model'}, expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'Invalid model type provided', str(response.data['exists_for_model'])
|
||||
)
|
||||
|
||||
# Invalid 'exists_for_model_id' value
|
||||
for model_id in ['not_an_integer', -1, 9999]:
|
||||
response = self.get(
|
||||
url,
|
||||
{'exists_for_model': 'part.part', 'exists_for_model_id': model_id},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
# Invalid 'exists_for_related_model' value
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'exists_for_model': 'part',
|
||||
'exists_for_related_model': 'invalid_field',
|
||||
'exists_for_related_model_id': 1,
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'no such field on the base model',
|
||||
str(response.data['exists_for_related_model']),
|
||||
)
|
||||
|
||||
def test_parameter_api(self):
|
||||
"""Test Parameter API functionality."""
|
||||
# Create a simple part to test with
|
||||
|
||||
@@ -1659,9 +1659,9 @@ pynacl==1.6.2 \
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# paramiko
|
||||
pypdf==6.7.1 \
|
||||
--hash=sha256:6b7a63be5563a0a35d54c6d6b550d75c00b8ccf36384be96365355e296e6b3b0 \
|
||||
--hash=sha256:a02ccbb06463f7c334ce1612e91b3e68a8e827f3cee100b9941771e6066b094e
|
||||
pypdf==6.6.2 \
|
||||
--hash=sha256:0a3ea3b3303982333404e22d8f75d7b3144f9cf4b2970b96856391a516f9f016 \
|
||||
--hash=sha256:44c0c9811cfb3b83b28f1c3d054531d5b8b81abaedee0d8cb403650d023832ba
|
||||
# via
|
||||
# -c src/backend/requirements.txt
|
||||
# -r src/backend/requirements.in
|
||||
|
||||
Reference in New Issue
Block a user