diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 9e47af93e9..c7743f1f13 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/filters.py b/src/backend/InvenTree/InvenTree/filters.py index f6371b4fc9..c3bf40f8e1 100644 --- a/src/backend/InvenTree/InvenTree/filters.py +++ b/src/backend/InvenTree/InvenTree/filters.py @@ -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, diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index ab3cf63d0c..2eb53025e9 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -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.""" diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 523dbba615..079987fc77 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -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')