2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-03 15:52:51 +00:00

Refactor API endpoint: Part (3/6) (#10441)

* refactor(api):  cascade and category filters in PartList

* Refactor exclude and relate parts in PartFilter

* add output options for Part and BOM endpoints

* add NumericInFilter for excluding parts by ID and related parts

* refactor: clean up unused code and improve output options in API

* Bump API version to 395 and update changelog with recent endpoint refactors

* Fix formatting of API version history in api_version.py

* Update API version history dates in api_version.py

* Enhance NumericInFilter tests: add handling for non-existent IDs, empty strings, and invalid inputs
This commit is contained in:
Reza
2025-10-02 15:26:41 +03:30
committed by GitHub
parent acb84100ab
commit 256f22b23d
4 changed files with 293 additions and 137 deletions

View File

@@ -1,21 +1,27 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 394 INVENTREE_API_VERSION = 395
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v394 -> 2025-09-01 : https://github.com/inventree/InvenTree/pull/10438 v395 -> 2025-10-01 : https://github.com/inventree/InvenTree/pull/10441
- Refactors 'bom_item_detail', 'assembly_detail', 'part_detail', 'build_detail' and 'allocations' param in user BuildLine API endpoint - Refactors 'parameters', 'category_detail', 'location_detail' and 'path_detail' params in Part API endpoint
- Refactors 'part_detail', 'location_detail', 'stock_detail' and 'build_detail' param in user BuildItem API endpoint - Refactors 'can_build', 'part_detail', 'sub_part_detail' and 'path_detail' params in BOM API endpoint
- Refactors 'path_detail' params in user Category API endpoint
- Refactors 'exclude_id', 'related' and 'exclude_related' params in Part API endpoint
v393 -> 2025-09-01 : https://github.com/inventree/InvenTree/pull/10437 v394 -> 2025-10-01 : https://github.com/inventree/InvenTree/pull/10438
- Refactors 'user_detail', 'permission_detail', 'role_detail' param in user GroupList API endpoint - Refactors 'bom_item_detail', 'assembly_detail', 'part_detail', 'build_detail' and 'allocations' params in BuildLine API endpoint
- Refactors 'part_detail', 'location_detail', 'stock_detail' and 'build_detail' params in BuildItem API endpoint
v393 -> 2025-10-01 : https://github.com/inventree/InvenTree/pull/10437
- Refactors 'user_detail', 'permission_detail', 'role_detail' params in user GroupList API endpoint
v392 -> 2025-09-22 : https://github.com/inventree/InvenTree/pull/10374 v392 -> 2025-09-22 : https://github.com/inventree/InvenTree/pull/10374
- Refactors 'part_detail', 'supplier_detail', 'manufacturer_detail'and 'pretty' param in SupplierPartList API endpoint - Refactors 'part_detail', 'supplier_detail', 'manufacturer_detail'and 'pretty' params in SupplierPartList API endpoint
v391 -> 2025-09-06 : https://github.com/inventree/InvenTree/pull/10279 v391 -> 2025-09-06 : https://github.com/inventree/InvenTree/pull/10279
- Refactors 'exclude_tree', 'cascade', and 'location' filters in StockList API endpoint - Refactors 'exclude_tree', 'cascade', and 'location' filters in StockList API endpoint

View File

@@ -3,6 +3,7 @@
from datetime import datetime from datetime import datetime
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils import timezone from django.utils import timezone
from django.utils.timezone import make_aware from django.utils.timezone import make_aware
@@ -192,6 +193,33 @@ class NumberOrNullFilter(rest_filters.NumberFilter):
return field return field
class NumericInFilter(rest_filters.BaseInFilter):
"""A filter that only accepts numeric values for 'in' queries.
This filter ensures that all provided values can be converted to integers
before passing them to the parent filter. Any non-numeric values will
be ignored (or optionally, a ValidationError can be raised).
"""
def filter(self, qs, value):
"""Filter the queryset based on numeric values only."""
if not value:
return qs
# Check that all values are numeric
numeric_values = []
for v in value:
try:
numeric_values.append(int(v))
except (ValueError, TypeError):
raise ValidationError(f"'{v}' is not a valid number")
if not numeric_values:
return qs
return super().filter(qs, numeric_values)
SEARCH_ORDER_FILTER = [ SEARCH_ORDER_FILTER = [
drf_backend.DjangoFilterBackend, drf_backend.DjangoFilterBackend,
InvenTreeSearchFilter, InvenTreeSearchFilter,

View File

@@ -22,6 +22,7 @@ from InvenTree.api import (
ListCreateDestroyAPIView, ListCreateDestroyAPIView,
MetadataView, MetadataView,
) )
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import ( from InvenTree.filters import (
ORDER_FILTER, ORDER_FILTER,
ORDER_FILTER_ALIAS, ORDER_FILTER_ALIAS,
@@ -29,13 +30,16 @@ from InvenTree.filters import (
SEARCH_ORDER_FILTER_ALIAS, SEARCH_ORDER_FILTER_ALIAS,
InvenTreeDateFilter, InvenTreeDateFilter,
InvenTreeSearchFilter, InvenTreeSearchFilter,
NumberOrNullFilter,
NumericInFilter,
) )
from InvenTree.helpers import isNull, str2bool from InvenTree.helpers import str2bool
from InvenTree.mixins import ( from InvenTree.mixins import (
CreateAPI, CreateAPI,
CustomRetrieveUpdateDestroyAPI, CustomRetrieveUpdateDestroyAPI,
ListAPI, ListAPI,
ListCreateAPI, ListCreateAPI,
OutputOptionsMixin,
RetrieveAPI, RetrieveAPI,
RetrieveUpdateAPI, RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI, RetrieveUpdateDestroyAPI,
@@ -67,17 +71,6 @@ class CategoryMixin:
serializer_class = part_serializers.CategorySerializer serializer_class = part_serializers.CategorySerializer
queryset = PartCategory.objects.all() queryset = PartCategory.objects.all()
def get_serializer(self, *args, **kwargs):
"""Add additional context based on query parameters."""
try:
params = self.request.query_params
kwargs['path_detail'] = str2bool(params.get('path_detail', False))
except AttributeError:
pass
return super().get_serializer(*args, **kwargs)
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset for the CategoryDetail endpoint.""" """Return an annotated queryset for the CategoryDetail endpoint."""
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
@@ -235,7 +228,19 @@ class CategoryFilter(FilterSet):
return queryset return queryset
class CategoryList(CategoryMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI): class CategoryOutputOption(OutputConfiguration):
"""Output option for PartCategory endpoints."""
OPTIONS = [InvenTreeOutputOption(flag='path_detail')]
class CategoryList(
CategoryMixin,
BulkUpdateMixin,
DataExportViewMixin,
OutputOptionsMixin,
ListCreateAPI,
):
"""API endpoint for accessing a list of PartCategory objects. """API endpoint for accessing a list of PartCategory objects.
- GET: Return a list of PartCategory objects - GET: Return a list of PartCategory objects
@@ -246,6 +251,8 @@ class CategoryList(CategoryMixin, BulkUpdateMixin, DataExportViewMixin, ListCrea
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER
output_options = CategoryOutputOption
ordering_fields = ['name', 'pathstring', 'level', 'tree_id', 'lft', 'part_count'] ordering_fields = ['name', 'pathstring', 'level', 'tree_id', 'lft', 'part_count']
# Use hierarchical ordering by default # Use hierarchical ordering by default
@@ -254,9 +261,11 @@ class CategoryList(CategoryMixin, BulkUpdateMixin, DataExportViewMixin, ListCrea
search_fields = ['name', 'description', 'pathstring'] search_fields = ['name', 'description', 'pathstring']
class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI): class CategoryDetail(CategoryMixin, OutputOptionsMixin, CustomRetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single PartCategory object.""" """API endpoint for detail view of a single PartCategory object."""
output_options = CategoryOutputOption
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
"""Perform 'update' function and mark this part as 'starred' (or not).""" """Perform 'update' function and mark this part as 'starred' (or not)."""
# Clean up input data # Clean up input data
@@ -909,6 +918,101 @@ class PartFilter(FilterSet):
label='Updated after', field_name='creation_date', lookup_expr='gt' label='Updated after', field_name='creation_date', lookup_expr='gt'
) )
exclude_id = NumericInFilter(
field_name='id',
lookup_expr='in',
exclude=True,
help_text='Exclude parts with these IDs (comma-separated)',
)
related = rest_filters.NumberFilter(
method='filter_related_parts', help_text='Show parts related to this part ID'
)
exclude_related = rest_filters.NumberFilter(
method='filter_exclude_related_parts',
help_text='Exclude parts related to this part ID',
)
def filter_related_parts(self, queryset, name, value):
"""Filter parts related to the specified part ID."""
if not value:
return queryset
try:
related_part = Part.objects.get(pk=value)
part_ids = self._get_related_part_ids(related_part)
return queryset.filter(pk__in=list(part_ids))
except (ValueError, Part.DoesNotExist):
return queryset.none()
def filter_exclude_related_parts(self, queryset, name, value):
"""Exclude parts related to the specified part ID."""
if not value:
return queryset
try:
related_part = Part.objects.get(pk=value)
part_ids = self._get_related_part_ids(related_part)
return queryset.exclude(pk__in=list(part_ids))
except (ValueError, Part.DoesNotExist):
return queryset
def _get_related_part_ids(self, related_part):
"""Return a set of part IDs which are related to the specified part."""
part_ids = set()
pk = related_part.pk
relation_filter = Q(part_1=related_part) | Q(part_2=related_part)
for relation in PartRelated.objects.filter(relation_filter).distinct():
if relation.part_1.pk != pk:
part_ids.add(relation.part_1.pk)
if relation.part_2.pk != pk:
part_ids.add(relation.part_2.pk)
return part_ids
cascade = rest_filters.BooleanFilter(
method='filter_cascade',
label=_('Cascade Categories'),
help_text=_('If true, include items in child categories of the given category'),
)
category = NumberOrNullFilter(
method='filter_category',
label=_('Category'),
help_text=_("Filter by numeric category ID or the literal 'null'"),
)
def filter_cascade(self, queryset, name, value):
"""Dummy filter method for 'cascade'.
- Ensures 'cascade' appears in API documentation
- Does NOT actually filter the queryset directly
"""
return queryset
def filter_category(self, queryset, name, value):
"""Filter for category that also applies cascade logic."""
cascade = str2bool(self.data.get('cascade', True))
if value == 'null':
if not cascade:
return queryset.filter(category=None)
return queryset
if not cascade:
return queryset.filter(category=value)
try:
category = PartCategory.objects.get(pk=value)
except PartCategory.DoesNotExist:
return queryset
children = category.getUniqueChildren()
return queryset.filter(category__in=children)
class PartMixin: class PartMixin:
"""Mixin class for Part API endpoints.""" """Mixin class for Part API endpoints."""
@@ -952,17 +1056,6 @@ class PartMixin:
kwargs['starred_parts'] = self.starred_parts kwargs['starred_parts'] = self.starred_parts
try:
params = self.request.query_params
kwargs['parameters'] = str2bool(params.get('parameters', None))
kwargs['category_detail'] = str2bool(params.get('category_detail', False))
kwargs['location_detail'] = str2bool(params.get('location_detail', False))
kwargs['path_detail'] = str2bool(params.get('path_detail', False))
except AttributeError:
pass
return super().get_serializer(*args, **kwargs) return super().get_serializer(*args, **kwargs)
def get_serializer_context(self): def get_serializer_context(self):
@@ -973,100 +1066,32 @@ class PartMixin:
return context return context
class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI): class PartOutputOptions(OutputConfiguration):
"""Output options for Part endpoints."""
OPTIONS = [
InvenTreeOutputOption(
'parameters', description='Include part parameters in response'
),
InvenTreeOutputOption('category_detail'),
InvenTreeOutputOption('location_detail'),
InvenTreeOutputOption('path_detail'),
]
class PartList(
PartMixin, BulkUpdateMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI
):
"""API endpoint for accessing a list of Part objects, or creating a new Part instance.""" """API endpoint for accessing a list of Part objects, or creating a new Part instance."""
output_options = PartOutputOptions
filterset_class = PartFilter filterset_class = PartFilter
is_create = True is_create = True
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
"""Perform custom filtering of the queryset.""" """Perform custom filtering of the queryset."""
params = self.request.query_params
queryset = super().filter_queryset(queryset) queryset = super().filter_queryset(queryset)
# Exclude specific part ID values?
exclude_id = []
for key in ['exclude_id', 'exclude_id[]']:
if key in params:
exclude_id += params.getlist(key, [])
if exclude_id:
id_values = []
for val in exclude_id:
try:
# pk values must be integer castable
val = int(val)
id_values.append(val)
except ValueError:
pass
queryset = queryset.exclude(pk__in=id_values)
# Filter by 'related' parts?
related = params.get('related', None)
exclude_related = params.get('exclude_related', None)
if related is not None or exclude_related is not None:
try:
pk = related if related is not None else exclude_related
pk = int(pk)
related_part = Part.objects.get(pk=pk)
part_ids = set()
# Return any relationship which points to the part in question
relation_filter = Q(part_1=related_part) | Q(part_2=related_part)
for relation in PartRelated.objects.filter(relation_filter).distinct():
if relation.part_1.pk != pk:
part_ids.add(relation.part_1.pk)
if relation.part_2.pk != pk:
part_ids.add(relation.part_2.pk)
if related is not None:
# Only return related results
queryset = queryset.filter(pk__in=list(part_ids))
elif exclude_related is not None:
# Exclude related results
queryset = queryset.exclude(pk__in=list(part_ids))
except (ValueError, Part.DoesNotExist):
pass
# Cascade? (Default = True)
cascade = str2bool(params.get('cascade', True))
# Does the user wish to filter by category?
cat_id = params.get('category', None)
if cat_id is not None:
# Category has been specified!
if isNull(cat_id):
# A 'null' category is the top-level category
if not cascade:
# Do not cascade, only list parts in the top-level category
queryset = queryset.filter(category=None)
else:
try:
category = PartCategory.objects.get(pk=cat_id)
# If '?cascade=true' then include parts which exist in sub-categories
if cascade:
queryset = queryset.filter(
category__in=category.getUniqueChildren()
)
# Just return parts directly in the requested category
else:
queryset = queryset.filter(category=cat_id)
except (ValueError, PartCategory.DoesNotExist):
pass
queryset = self.filter_parametric_data(queryset) queryset = self.filter_parametric_data(queryset)
queryset = self.order_by_parameter(queryset) queryset = self.order_by_parameter(queryset)
@@ -1174,9 +1199,11 @@ class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI):
] ]
class PartDetail(PartMixin, RetrieveUpdateDestroyAPI): class PartDetail(PartMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single Part object.""" """API endpoint for detail view of a single Part object."""
output_options = PartOutputOptions
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
"""Custom update functionality for Part instance. """Custom update functionality for Part instance.
@@ -1594,23 +1621,7 @@ class BomMixin:
queryset = BomItem.objects.all() queryset = BomItem.objects.all()
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
"""Return the serializer instance for this API endpoint. """Return the serializer instance for this API endpoint."""
If requested, extra detail fields are annotated to the queryset:
- part_detail
- sub_part_detail
"""
# Do we wish to include extra detail?
try:
params = self.request.query_params
kwargs['can_build'] = str2bool(params.get('can_build', True))
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
kwargs['sub_part_detail'] = str2bool(params.get('sub_part_detail', False))
except AttributeError:
pass
# Ensure the request context is passed through! # Ensure the request context is passed through!
kwargs['context'] = self.get_serializer_context() kwargs['context'] = self.get_serializer_context()
@@ -1625,13 +1636,26 @@ class BomMixin:
return queryset return queryset
class BomList(BomMixin, DataExportViewMixin, ListCreateDestroyAPIView): class BomOutputOptions(OutputConfiguration):
"""Output options for BOM endpoints."""
OPTIONS = [
InvenTreeOutputOption('can_build', default=True),
InvenTreeOutputOption('part_detail'),
InvenTreeOutputOption('sub_part_detail'),
]
class BomList(
BomMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateDestroyAPIView
):
"""API endpoint for accessing a list of BomItem objects. """API endpoint for accessing a list of BomItem objects.
- GET: Return list of BomItem objects - GET: Return list of BomItem objects
- POST: Create a new BomItem object - POST: Create a new BomItem object
""" """
output_options = BomOutputOptions
filterset_class = BomFilter filterset_class = BomFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS filter_backends = SEARCH_ORDER_FILTER_ALIAS
@@ -1680,9 +1704,11 @@ class BomList(BomMixin, DataExportViewMixin, ListCreateDestroyAPIView):
bom_item.check_part_lock(bom_item.part) bom_item.check_part_lock(bom_item.part)
class BomDetail(BomMixin, RetrieveUpdateDestroyAPI): class BomDetail(BomMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single BomItem object.""" """API endpoint for detail view of a single BomItem object."""
output_options = BomOutputOptions
class BomItemValidate(UpdateAPI): class BomItemValidate(UpdateAPI):
"""API endpoint for validating a BomItem.""" """API endpoint for validating a BomItem."""

View File

@@ -906,6 +906,82 @@ class PartAPITest(PartAPITestBase):
response = self.get(url, {'related': 1}, expected_code=200) response = self.get(url, {'related': 1}, expected_code=200)
self.assertEqual(len(response.data), 2) self.assertEqual(len(response.data), 2)
def test_exclude_related(self):
"""Test that we can exclude parts related to a specific part ID."""
url = reverse('api-part-list')
# Get initial count of all parts
response = self.get(url, {}, expected_code=200)
initial_count = len(response.data)
# Add some relationships
PartRelated.objects.create(
part_1=Part.objects.get(pk=1), part_2=Part.objects.get(pk=2)
)
PartRelated.objects.create(
part_2=Part.objects.get(pk=1), part_1=Part.objects.get(pk=3)
)
# Test excluding parts related to part 1
# Parts 2 and 3 are related to part 1, so they should be excluded
response = self.get(url, {'exclude_related': 1}, expected_code=200)
self.assertEqual(len(response.data), initial_count - 2)
# Verify that parts 2 and 3 are not in the results
part_ids = [part['pk'] for part in response.data]
self.assertNotIn(2, part_ids)
self.assertNotIn(3, part_ids)
self.assertIn(1, part_ids)
# Test excluding with a part that has no relations
# This should return all parts
response = self.get(url, {'exclude_related': 99}, expected_code=200)
self.assertEqual(len(response.data), initial_count)
def test_exclude_id(self):
"""Test that we can exclude parts by ID using the exclude_id parameter."""
url = reverse('api-part-list')
# Get initial count of all parts
response = self.get(url, {}, expected_code=200)
initial_count = len(response.data)
all_part_ids = {part['pk'] for part in response.data}
# Exclude a single valid part ID
response = self.get(url, {'exclude_id': '1'}, expected_code=200)
self.assertEqual(len(response.data), initial_count - 1)
part_ids = {part['pk'] for part in response.data}
self.assertNotIn(1, part_ids)
# Exclude multiple valid part IDs (comma-separated)
response = self.get(url, {'exclude_id': '1,2,3'}, expected_code=200)
self.assertEqual(len(response.data), initial_count - 3)
part_ids = {part['pk'] for part in response.data}
self.assertNotIn(1, part_ids)
self.assertNotIn(2, part_ids)
self.assertNotIn(3, part_ids)
# Exclude non-existent part ID (should not affect results)
non_existent_id = max(all_part_ids) + 1000
response = self.get(
url, {'exclude_id': str(non_existent_id)}, expected_code=200
)
self.assertEqual(len(response.data), initial_count)
# Exclude with empty string (should return all parts)
response = self.get(url, {'exclude_id': ''}, expected_code=200)
self.assertEqual(len(response.data), initial_count)
# Invalid input - non-numeric value (should raise ValidationError)
response = self.get(url, {'exclude_id': 'abc'}, expected_code=400)
# Zero as ID
response = self.get(url, {'exclude_id': '0'}, expected_code=200)
# Assuming 0 is not a valid part ID in the system
self.assertEqual(len(response.data), initial_count)
def test_filter_by_bom_valid(self): def test_filter_by_bom_valid(self):
"""Test the 'bom_valid' Part API filter.""" """Test the 'bom_valid' Part API filter."""
url = reverse('api-part-list') url = reverse('api-part-list')
@@ -1904,6 +1980,16 @@ class PartDetailTests(PartImageTestMixin, PartAPITestBase):
self.assertIn('category_path', response.data) self.assertIn('category_path', response.data)
self.assertEqual(len(response.data['category_path']), 2) self.assertEqual(len(response.data['category_path']), 2)
def test_location_detail(self):
"""Check that location_detail can be requested against the serializer."""
response = self.get(
reverse('api-part-detail', kwargs={'pk': 1}),
{'location_detail': True},
expected_code=200,
)
self.assertIn('default_location_detail', response.data)
def test_part_requirements(self): def test_part_requirements(self):
"""Unit test for the "PartRequirements" API endpoint.""" """Unit test for the "PartRequirements" API endpoint."""
url = reverse('api-part-requirements', kwargs={'pk': Part.objects.first().pk}) url = reverse('api-part-requirements', kwargs={'pk': Part.objects.first().pk})
@@ -2578,6 +2664,16 @@ class BomItemTest(InvenTreeAPITestCase):
self.assertEqual(int(float(response.data['quantity'])), 57) self.assertEqual(int(float(response.data['quantity'])), 57)
self.assertEqual(response.data['note'], 'Added a note') self.assertEqual(response.data['note'], 'Added a note')
def test_output_options(self):
"""Test that various output options work as expected."""
url = reverse('api-bom-item-detail', kwargs={'pk': 3})
options = ['can_build', 'part_detail', 'sub_part_detail']
for option in options:
response = self.get(url, {f'{option}': True}, expected_code=200)
self.assertIn(option, response.data)
response = self.get(url, {f'{option}': False}, expected_code=200)
self.assertNotIn(option, response.data)
def test_add_bom_item(self): def test_add_bom_item(self):
"""Test that we can create a new BomItem via the API.""" """Test that we can create a new BomItem via the API."""
url = reverse('api-bom-list') url = reverse('api-bom-list')