diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index c7743f1f13..0cbd5eadda 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,17 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 395 +INVENTREE_API_VERSION = 396 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v396 -> 2025-10-01 : https://github.com/inventree/InvenTree/pull/10443 + - Refactors 'part_detail', 'manufacturer_detail' and 'pretty' params in Manufacturer API endpoint + - Refactors 'manufacturer_part_detail' param in ManufacturerPartParameter API endpoint + - Refactors 'part_detail' and 'supplier_detail' param in SupplierPriceBreak 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 diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 9f03d9f6c6..e5d23a5e9b 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -12,7 +12,6 @@ from data_exporter.mixins import DataExportViewMixin from InvenTree.api import ListCreateDestroyAPIView, MetadataView from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS -from InvenTree.helpers import str2bool from InvenTree.mixins import ListCreateAPI, OutputOptionsMixin, RetrieveUpdateDestroyAPI from .models import ( @@ -148,7 +147,31 @@ class ManufacturerPartFilter(FilterSet): ) -class ManufacturerPartList(DataExportViewMixin, ListCreateDestroyAPIView): +class ManufacturerOutputOptions(OutputConfiguration): + """Available output options for the ManufacturerPart endpoints.""" + + OPTIONS = [ + InvenTreeOutputOption( + description='Include detailed information about the linked Part in the response', + flag='part_detail', + default=False, + ), + InvenTreeOutputOption( + description='Include detailed information about the Manufacturer in the response', + flag='manufacturer_detail', + default=False, + ), + InvenTreeOutputOption( + description='Format the output with a more readable (pretty) name', + flag='pretty', + default=False, + ), + ] + + +class ManufacturerPartList( + DataExportViewMixin, OutputOptionsMixin, ListCreateDestroyAPIView +): """API endpoint for list view of ManufacturerPart object. - GET: Return list of ManufacturerPart objects @@ -161,24 +184,7 @@ class ManufacturerPartList(DataExportViewMixin, ListCreateDestroyAPIView): serializer_class = ManufacturerPartSerializer filterset_class = ManufacturerPartFilter - - def get_serializer(self, *args, **kwargs): - """Return serializer instance for this endpoint.""" - # Do we wish to include extra detail? - try: - params = self.request.query_params - - kwargs['part_detail'] = str2bool(params.get('part_detail', None)) - kwargs['manufacturer_detail'] = str2bool( - params.get('manufacturer_detail', None) - ) - kwargs['pretty'] = str2bool(params.get('pretty', None)) - except AttributeError: - pass - - kwargs['context'] = self.get_serializer_context() - - return super().get_serializer(*args, **kwargs) + output_options = ManufacturerOutputOptions filter_backends = SEARCH_ORDER_FILTER @@ -224,30 +230,25 @@ class ManufacturerPartParameterFilter(FilterSet): ) -class ManufacturerPartParameterList(ListCreateDestroyAPIView): +class ManufacturerPartParameterOptions(OutputConfiguration): + """Available output options for the ManufacturerPartParameter endpoints.""" + + OPTIONS = [ + InvenTreeOutputOption( + description='Include detailed information about the linked ManufacturerPart in the response', + flag='manufacturer_part_detail', + default=False, + ) + ] + + +class ManufacturerPartParameterList(ListCreateDestroyAPIView, OutputOptionsMixin): """API endpoint for list view of ManufacturerPartParamater model.""" queryset = ManufacturerPartParameter.objects.all() serializer_class = ManufacturerPartParameterSerializer filterset_class = ManufacturerPartParameterFilter - - def get_serializer(self, *args, **kwargs): - """Return serializer instance for this endpoint.""" - # Do we wish to include any extra detail? - try: - params = self.request.query_params - - optional_fields = ['manufacturer_part_detail'] - - for key in optional_fields: - kwargs[key] = str2bool(params.get(key, None)) - - except AttributeError: - pass - - kwargs['context'] = self.get_serializer_context() - - return super().get_serializer(*args, **kwargs) + output_options = ManufacturerPartParameterOptions filter_backends = SEARCH_ORDER_FILTER @@ -454,7 +455,24 @@ class SupplierPriceBreakFilter(FilterSet): ) -class SupplierPriceBreakList(ListCreateAPI): +class SupplierPriceBreakOutputOptions(OutputConfiguration): + """Available output options for the SupplierPriceBreak endpoints.""" + + OPTIONS = [ + InvenTreeOutputOption( + description='Include detailed information about the linked Part in the response', + flag='part_detail', + default=False, + ), + InvenTreeOutputOption( + description='Include detailed information about the Supplier in the response', + flag='supplier_detail', + default=False, + ), + ] + + +class SupplierPriceBreakList(OutputOptionsMixin, ListCreateAPI): """API endpoint for list view of SupplierPriceBreak object. - GET: Retrieve list of SupplierPriceBreak objects @@ -464,6 +482,7 @@ class SupplierPriceBreakList(ListCreateAPI): queryset = SupplierPriceBreak.objects.all() serializer_class = SupplierPriceBreakSerializer filterset_class = SupplierPriceBreakFilter + output_options = SupplierPriceBreakOutputOptions def get_queryset(self): """Return annotated queryset for the SupplierPriceBreak list endpoint.""" @@ -472,21 +491,6 @@ class SupplierPriceBreakList(ListCreateAPI): return queryset - def get_serializer(self, *args, **kwargs): - """Return serializer instance for this endpoint.""" - try: - params = self.request.query_params - - kwargs['part_detail'] = str2bool(params.get('part_detail', False)) - kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', False)) - - except AttributeError: - pass - - kwargs['context'] = self.get_serializer_context() - - return super().get_serializer(*args, **kwargs) - filter_backends = SEARCH_ORDER_FILTER_ALIAS ordering_fields = ['quantity', 'supplier', 'SKU', 'price'] diff --git a/src/backend/InvenTree/company/test_api.py b/src/backend/InvenTree/company/test_api.py index a0920c393c..85219416c3 100644 --- a/src/backend/InvenTree/company/test_api.py +++ b/src/backend/InvenTree/company/test_api.py @@ -2,7 +2,14 @@ from django.urls import reverse -from company.models import Address, Company, Contact, ManufacturerPart, SupplierPart +from company.models import ( + Address, + Company, + Contact, + ManufacturerPart, + SupplierPart, + SupplierPriceBreak, +) from InvenTree.unit_test import InvenTreeAPITestCase from part.models import Part from users.permissions import check_user_permission @@ -529,6 +536,34 @@ class ManufacturerTest(InvenTreeAPITestCase): 'https://www.axel-larsson.se/Exego.aspx?p_id=341&ArtNr=0804020E', ) + def test_output_options(self): + """Test the output options for SupplierPart detail.""" + url = reverse('api-manufacturer-part-list') + + # Test cases: (parameter_name, response_field_name) + test_cases = [ + ('part_detail', 'part_detail'), + ('manufacturer_detail', 'manufacturer_detail'), + ('pretty', 'pretty_name'), + ] + + for param, field in test_cases: + # Test with parameter set to 'true' + response = self.get(url, {param: 'true', 'limit': 1}, expected_code=200) + self.assertIn( + field, + response.data['results'][0], + f"Field '{field}' should be present when {param}='true'", + ) + + # Test with parameter set to 'false' + response = self.get(url, {param: 'false', 'limit': 1}, expected_code=200) + self.assertNotIn( + field, + response.data['results'][0], + f"Field '{field}' should not be present when {param}='false'", + ) + class SupplierPartTest(InvenTreeAPITestCase): """Unit tests for the SupplierPart API endpoints.""" @@ -570,23 +605,30 @@ class SupplierPartTest(InvenTreeAPITestCase): sp = SupplierPart.objects.all().first() url = reverse('api-supplier-part-detail', kwargs={'pk': sp.pk}) - response = self.get(url, {'part_detail': 'true'}, expected_code=200) - self.assertIn('part_detail', response.data) + # Test cases: (parameter_name, response_field_name) + test_cases = [ + ('part_detail', 'part_detail'), + ('supplier_detail', 'supplier_detail'), + ('manufacturer_detail', 'manufacturer_detail'), + ('pretty', 'pretty_name'), + ] - response = self.get(url, {'supplier_detail': 'true'}, expected_code=200) - self.assertIn('supplier_detail', response.data) + for param, field in test_cases: + # Test with parameter set to 'true' + response = self.get(url, {param: 'true'}, expected_code=200) + self.assertIn( + field, + response.data, + f"Field '{field}' should be present when {param}='true'", + ) - response = self.get(url, {'supplier_detail': 'false'}, expected_code=200) - self.assertNotIn('supplier_detail', response.data) - - response = self.get(url, {'manufacturer_detail': 'true'}, expected_code=200) - self.assertIn('manufacturer_detail', response.data) - - response = self.get(url, {'pretty': 'true'}, expected_code=200) - self.assertIn('pretty_name', response.data) - - response = self.get(url, {'pretty': 'false'}, expected_code=200) - self.assertNotIn('pretty_name', response.data) + # Test with parameter set to 'false' + response = self.get(url, {param: 'false'}, expected_code=200) + self.assertNotIn( + field, + response.data, + f"Field '{field}' should not be present when {param}='false'", + ) def test_available(self): """Tests for updating the 'available' field.""" @@ -728,3 +770,73 @@ class CompanyMetadataAPITest(InvenTreeAPITestCase): 'api-contact-metadata': Contact, }.items(): self.metatester(apikey, model) + + +class SupplierPriceBreakAPITest(InvenTreeAPITestCase): + """Unit tests for the SupplierPart price break API.""" + + fixtures = [ + 'category', + 'part', + 'location', + 'company', + 'manufacturer_part', + 'supplier_part', + 'price_breaks', + ] + + roles = ['company.change', 'purchase_order.change', 'part.change'] + + def test_output_options(self): + """Test the output options for SupplierPart price break list.""" + url = reverse('api-part-supplier-price-list') + test_cases = [ + ('part_detail', 'part_detail'), + ('supplier_detail', 'supplier_detail'), + ] + + for param, field in test_cases: + # Test with parameter set to 'true' + response = self.get(url, {param: 'true', 'limit': 1}, expected_code=200) + self.assertIn( + field, + response.data['results'][0], + f"Field '{field}' should be present when {param}='true'", + ) + + # Test with parameter set to 'false' + response = self.get(url, {param: 'false', 'limit': 1}, expected_code=200) + self.assertNotIn( + field, + response.data['results'][0], + f"Field '{field}' should not be present when {param}='false'", + ) + + def test_supplier_price_break_list(self): + """Test the SupplierPriceBreak API list functionality.""" + url = reverse('api-part-supplier-price-list') + + # Return *all* SupplierPriceBreaks + response = self.get(url, {}, expected_code=200) + self.assertEqual(len(response.data), SupplierPriceBreak.objects.count()) + + # Filter by supplier part + expected = {1: 3, 2: 2, 4: 2} # Based on fixture data + + for part_pk, count in expected.items(): + response = self.get(url, {'part': part_pk}, expected_code=200) + self.assertEqual(len(response.data), count) + + # Test ordering by quantity + response = self.get(url, {'ordering': 'quantity'}, expected_code=200) + quantities = [item['quantity'] for item in response.data] + self.assertEqual(quantities, sorted(quantities)) + + # Test ordering by price + response = self.get(url, {'ordering': 'price'}, expected_code=200) + prices = [float(item['price']) for item in response.data] + self.assertEqual(prices, sorted(prices)) + + # Test search by supplier name + response = self.get(url, {'search': 'ACME'}, expected_code=200) + self.assertGreater(len(response.data), 0)