2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-03 07:42: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
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."""
INVENTREE_API_TEXT = """
v394 -> 2025-09-01 : https://github.com/inventree/InvenTree/pull/10438
- Refactors 'bom_item_detail', 'assembly_detail', 'part_detail', 'build_detail' and 'allocations' param in user BuildLine API endpoint
- Refactors 'part_detail', 'location_detail', 'stock_detail' and 'build_detail' param in user BuildItem API endpoint
v395 -> 2025-10-01 : https://github.com/inventree/InvenTree/pull/10441
- Refactors 'parameters', 'category_detail', 'location_detail' and 'path_detail' params in Part 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
- Refactors 'user_detail', 'permission_detail', 'role_detail' param in user GroupList API endpoint
v394 -> 2025-10-01 : https://github.com/inventree/InvenTree/pull/10438
- 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
- 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
- Refactors 'exclude_tree', 'cascade', and 'location' filters in StockList API endpoint

View File

@@ -3,6 +3,7 @@
from datetime import datetime
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.utils.timezone import make_aware
@@ -192,6 +193,33 @@ class NumberOrNullFilter(rest_filters.NumberFilter):
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 = [
drf_backend.DjangoFilterBackend,
InvenTreeSearchFilter,

View File

@@ -22,6 +22,7 @@ from InvenTree.api import (
ListCreateDestroyAPIView,
MetadataView,
)
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import (
ORDER_FILTER,
ORDER_FILTER_ALIAS,
@@ -29,13 +30,16 @@ from InvenTree.filters import (
SEARCH_ORDER_FILTER_ALIAS,
InvenTreeDateFilter,
InvenTreeSearchFilter,
NumberOrNullFilter,
NumericInFilter,
)
from InvenTree.helpers import isNull, str2bool
from InvenTree.helpers import str2bool
from InvenTree.mixins import (
CreateAPI,
CustomRetrieveUpdateDestroyAPI,
ListAPI,
ListCreateAPI,
OutputOptionsMixin,
RetrieveAPI,
RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI,
@@ -67,17 +71,6 @@ class CategoryMixin:
serializer_class = part_serializers.CategorySerializer
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):
"""Return an annotated queryset for the CategoryDetail endpoint."""
queryset = super().get_queryset(*args, **kwargs)
@@ -235,7 +228,19 @@ class CategoryFilter(FilterSet):
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.
- GET: Return a list of PartCategory objects
@@ -246,6 +251,8 @@ class CategoryList(CategoryMixin, BulkUpdateMixin, DataExportViewMixin, ListCrea
filter_backends = SEARCH_ORDER_FILTER
output_options = CategoryOutputOption
ordering_fields = ['name', 'pathstring', 'level', 'tree_id', 'lft', 'part_count']
# Use hierarchical ordering by default
@@ -254,9 +261,11 @@ class CategoryList(CategoryMixin, BulkUpdateMixin, DataExportViewMixin, ListCrea
search_fields = ['name', 'description', 'pathstring']
class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI):
class CategoryDetail(CategoryMixin, OutputOptionsMixin, CustomRetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single PartCategory object."""
output_options = CategoryOutputOption
def update(self, request, *args, **kwargs):
"""Perform 'update' function and mark this part as 'starred' (or not)."""
# Clean up input data
@@ -909,6 +918,101 @@ class PartFilter(FilterSet):
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:
"""Mixin class for Part API endpoints."""
@@ -952,17 +1056,6 @@ class PartMixin:
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)
def get_serializer_context(self):
@@ -973,100 +1066,32 @@ class PartMixin:
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."""
output_options = PartOutputOptions
filterset_class = PartFilter
is_create = True
def filter_queryset(self, queryset):
"""Perform custom filtering of the queryset."""
params = self.request.query_params
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.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."""
output_options = PartOutputOptions
def update(self, request, *args, **kwargs):
"""Custom update functionality for Part instance.
@@ -1594,23 +1621,7 @@ class BomMixin:
queryset = BomItem.objects.all()
def get_serializer(self, *args, **kwargs):
"""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
"""Return the serializer instance for this API endpoint."""
# Ensure the request context is passed through!
kwargs['context'] = self.get_serializer_context()
@@ -1625,13 +1636,26 @@ class BomMixin:
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.
- GET: Return list of BomItem objects
- POST: Create a new BomItem object
"""
output_options = BomOutputOptions
filterset_class = BomFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS
@@ -1680,9 +1704,11 @@ class BomList(BomMixin, DataExportViewMixin, ListCreateDestroyAPIView):
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."""
output_options = BomOutputOptions
class BomItemValidate(UpdateAPI):
"""API endpoint for validating a BomItem."""

View File

@@ -906,6 +906,82 @@ class PartAPITest(PartAPITestBase):
response = self.get(url, {'related': 1}, expected_code=200)
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):
"""Test the 'bom_valid' Part API filter."""
url = reverse('api-part-list')
@@ -1904,6 +1980,16 @@ class PartDetailTests(PartImageTestMixin, PartAPITestBase):
self.assertIn('category_path', response.data)
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):
"""Unit test for the "PartRequirements" API endpoint."""
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(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):
"""Test that we can create a new BomItem via the API."""
url = reverse('api-bom-list')