From 65c8af427f520b7ef5c614bd4e6e7635b1c8a7c8 Mon Sep 17 00:00:00 2001 From: Reza <50555450+Reza98Sh@users.noreply.github.com> Date: Sun, 5 Oct 2025 10:21:49 +0330 Subject: [PATCH] Refactor API endpoint: Order (6/6) (#10445) * add output options for PurchaseOrder, SalesOrder, and ReturnOrder endpoints * add output options for PurchaseOrder, SalesOrder, and ReturnOrder endpoints * add serializer context handling and update sales order fixture with additional line item * bump API version to 398 and update output options tests for PurchaseOrder endpoint * add output options tests for SalesOrder and ReturnOrder detail endpoints * fix typo --------- Co-authored-by: Matthias Mair --- .../InvenTree/InvenTree/api_version.py | 11 +- src/backend/InvenTree/InvenTree/mixins.py | 9 +- src/backend/InvenTree/order/api.py | 211 +++++++++++------- .../InvenTree/order/fixtures/sales_order.yaml | 42 ++++ src/backend/InvenTree/order/test_api.py | 161 ++++++++++++- 5 files changed, 342 insertions(+), 92 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 904e2f199d..3e70a5d89b 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,21 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 398 +INVENTREE_API_VERSION = 399 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v399 -> 2025-10-05 : https://github.com/inventree/InvenTree/pull/10445 + - Refactors 'customer_detail' param in SalesOrder API endpoint + - Refactors 'customer_detail' param in ReturnOrder API endpoint + - Refactors 'supplier_detail' param in PurchaseOrder API endpoint + - Refactors 'part_detail' and 'order_detail' params in PurchaseOrderLineItem API endpoint + - Refactors 'part_detail', 'item_detail' and 'order_detail' params in ReturnOrderLineItem API endpoint + - Refactors 'part_detail', 'order_detail' and 'customer_detail' params in SalesOrderLineItem API endpoint + - Refactors 'part_detail', 'item_detail', 'order_detail', 'location_detail' and 'customer_detail' params in SalesOrderAllocation API endpoint + v398 -> 2025-10-05 : https://github.com/inventree/InvenTree/pull/10487 - Refactors 'part_detail', 'path_detail', 'supplier_part_detail', 'location_detail' and 'tests' params in Stock API endpoint diff --git a/src/backend/InvenTree/InvenTree/mixins.py b/src/backend/InvenTree/InvenTree/mixins.py index 908e01240d..d5512cabc0 100644 --- a/src/backend/InvenTree/InvenTree/mixins.py +++ b/src/backend/InvenTree/InvenTree/mixins.py @@ -212,17 +212,14 @@ class DataImportExportSerializerMixin( class OutputOptionsMixin: """Mixin to handle output options for API endpoints.""" - output_options: OutputConfiguration + output_options: OutputConfiguration = None def __init_subclass__(cls, **kwargs): """Automatically attaches OpenAPI schema parameters for its output options.""" super().__init_subclass__(**kwargs) - if getattr(cls, 'output_options', None) is None: - raise ValueError( - f"Class {cls.__name__} must define 'output_options' attribute" - ) - schema_for_view_output_options(cls) + if getattr(cls, 'output_options', None) is not None: + schema_for_view_output_options(cls) def get_serializer(self, *args, **kwargs): """Return serializer instance with output options applied.""" diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 078e7b739d..91f8c64b7d 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -29,6 +29,7 @@ import stock.serializers as stock_serializers from data_exporter.mixins import DataExportViewMixin from generic.states.api import StatusView from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView +from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import ( SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS, @@ -36,7 +37,13 @@ from InvenTree.filters import ( ) from InvenTree.helpers import str2bool from InvenTree.helpers_model import construct_absolute_url, get_base_url -from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI, RetrieveUpdateDestroyAPI +from InvenTree.mixins import ( + CreateAPI, + ListAPI, + ListCreateAPI, + OutputOptionsMixin, + RetrieveUpdateDestroyAPI, +) from order import models, serializers from order.status_codes import ( PurchaseOrderStatus, @@ -345,6 +352,12 @@ class PurchaseOrderFilter(OrderFilter): return queryset.filter(lines__build_order=build).distinct() +class PurchaseOrderOutputOptions(OutputConfiguration): + """Output options for the PurchaseOrder endpoint.""" + + OPTIONS = [InvenTreeOutputOption('supplier_detail')] + + class PurchaseOrderMixin: """Mixin class for PurchaseOrder endpoints.""" @@ -353,16 +366,8 @@ class PurchaseOrderMixin: def get_serializer(self, *args, **kwargs): """Return the serializer instance for this endpoint.""" - try: - kwargs['supplier_detail'] = str2bool( - self.request.query_params.get('supplier_detail', False) - ) - except AttributeError: - pass - # Ensure the request context is passed through kwargs['context'] = self.get_serializer_context() - return super().get_serializer(*args, **kwargs) def get_queryset(self, *args, **kwargs): @@ -379,7 +384,11 @@ class PurchaseOrderMixin: class PurchaseOrderList( - PurchaseOrderMixin, OrderCreateMixin, DataExportViewMixin, ListCreateAPI + PurchaseOrderMixin, + OrderCreateMixin, + DataExportViewMixin, + OutputOptionsMixin, + ListCreateAPI, ): """API endpoint for accessing a list of PurchaseOrder objects. @@ -389,6 +398,7 @@ class PurchaseOrderList( filterset_class = PurchaseOrderFilter filter_backends = SEARCH_ORDER_FILTER_ALIAS + output_options = PurchaseOrderOutputOptions ordering_field_aliases = { 'reference': ['reference_int', 'reference'], @@ -421,9 +431,13 @@ class PurchaseOrderList( ordering = '-reference' -class PurchaseOrderDetail(PurchaseOrderMixin, RetrieveUpdateDestroyAPI): +class PurchaseOrderDetail( + PurchaseOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI +): """API endpoint for detail view of a PurchaseOrder object.""" + output_options = PurchaseOrderOutputOptions + class PurchaseOrderContextMixin: """Mixin to add purchase order object as serializer context variable.""" @@ -605,6 +619,15 @@ class PurchaseOrderLineItemFilter(LineItemFilter): ) +class PurchaseOrderLineItemOutputOptions(OutputConfiguration): + """Output options for the PurchaseOrderLineItem endpoint.""" + + OPTIONS = [ + InvenTreeOutputOption('part_detail'), + InvenTreeOutputOption('order_detail'), + ] + + class PurchaseOrderLineItemMixin: """Mixin class for PurchaseOrderLineItem endpoints.""" @@ -623,16 +646,6 @@ class PurchaseOrderLineItemMixin: def get_serializer(self, *args, **kwargs): """Return serializer instance for this endpoint.""" - try: - kwargs['part_detail'] = str2bool( - self.request.query_params.get('part_detail', False) - ) - kwargs['order_detail'] = str2bool( - self.request.query_params.get('order_detail', False) - ) - except AttributeError: - pass - kwargs['context'] = self.get_serializer_context() return super().get_serializer(*args, **kwargs) @@ -647,7 +660,10 @@ class PurchaseOrderLineItemMixin: class PurchaseOrderLineItemList( - PurchaseOrderLineItemMixin, DataExportViewMixin, ListCreateDestroyAPIView + PurchaseOrderLineItemMixin, + DataExportViewMixin, + OutputOptionsMixin, + ListCreateDestroyAPIView, ): """API endpoint for accessing a list of PurchaseOrderLineItem objects. @@ -656,6 +672,7 @@ class PurchaseOrderLineItemList( """ filterset_class = PurchaseOrderLineItemFilter + output_options = PurchaseOrderLineItemOutputOptions def create(self, request, *args, **kwargs): """Create or update a new PurchaseOrderLineItem object.""" @@ -732,9 +749,13 @@ class PurchaseOrderLineItemList( ] -class PurchaseOrderLineItemDetail(PurchaseOrderLineItemMixin, RetrieveUpdateDestroyAPI): +class PurchaseOrderLineItemDetail( + PurchaseOrderLineItemMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI +): """Detail API endpoint for PurchaseOrderLineItem object.""" + output_options = PurchaseOrderLineItemOutputOptions + class PurchaseOrderExtraLineList(GeneralExtraLineList, ListCreateAPI): """API endpoint for accessing a list of PurchaseOrderExtraLine objects.""" @@ -818,13 +839,6 @@ class SalesOrderMixin: def get_serializer(self, *args, **kwargs): """Return serializer instance for this endpoint.""" - try: - kwargs['customer_detail'] = str2bool( - self.request.query_params.get('customer_detail', False) - ) - except AttributeError: - pass - # Ensure the context is passed through to the serializer kwargs['context'] = self.get_serializer_context() @@ -843,8 +857,18 @@ class SalesOrderMixin: return queryset +class SalesOrderOutputOptions(OutputConfiguration): + """Output options for the SalesOrder endpoint.""" + + OPTIONS = [InvenTreeOutputOption('customer_detail')] + + class SalesOrderList( - SalesOrderMixin, OrderCreateMixin, DataExportViewMixin, ListCreateAPI + SalesOrderMixin, + OrderCreateMixin, + DataExportViewMixin, + OutputOptionsMixin, + ListCreateAPI, ): """API endpoint for accessing a list of SalesOrder objects. @@ -856,6 +880,8 @@ class SalesOrderList( filter_backends = SEARCH_ORDER_FILTER_ALIAS + output_options = SalesOrderOutputOptions + ordering_field_aliases = { 'reference': ['reference_int', 'reference'], 'project_code': ['project_code__code'], @@ -889,9 +915,11 @@ class SalesOrderList( ordering = '-reference' -class SalesOrderDetail(SalesOrderMixin, RetrieveUpdateDestroyAPI): +class SalesOrderDetail(SalesOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a SalesOrder object.""" + output_options = SalesOrderOutputOptions + class SalesOrderLineItemFilter(LineItemFilter): """Custom filters for SalesOrderLineItemList endpoint.""" @@ -1002,16 +1030,6 @@ class SalesOrderLineItemMixin: def get_serializer(self, *args, **kwargs): """Return serializer for this endpoint with extra data as requested.""" - try: - params = self.request.query_params - - kwargs['part_detail'] = str2bool(params.get('part_detail', False)) - kwargs['order_detail'] = str2bool(params.get('order_detail', False)) - kwargs['customer_detail'] = str2bool(params.get('customer_detail', False)) - - except AttributeError: - pass - kwargs['context'] = self.get_serializer_context() return super().get_serializer(*args, **kwargs) @@ -1038,8 +1056,18 @@ class SalesOrderLineItemMixin: return queryset +class SalesOrderLineItemOutputOptions(OutputConfiguration): + """Output options for the SalesOrderAllocation endpoint.""" + + OPTIONS = [ + InvenTreeOutputOption('part_detail'), + InvenTreeOutputOption('order_detail'), + InvenTreeOutputOption('customer_detail'), + ] + + class SalesOrderLineItemList( - SalesOrderLineItemMixin, DataExportViewMixin, ListCreateAPI + SalesOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI ): """API endpoint for accessing a list of SalesOrderLineItem objects.""" @@ -1047,6 +1075,8 @@ class SalesOrderLineItemList( filter_backends = SEARCH_ORDER_FILTER_ALIAS + output_options = SalesOrderLineItemOutputOptions + ordering_fields = [ 'customer', 'order', @@ -1069,9 +1099,13 @@ class SalesOrderLineItemList( search_fields = ['part__name', 'quantity', 'reference'] -class SalesOrderLineItemDetail(SalesOrderLineItemMixin, RetrieveUpdateDestroyAPI): +class SalesOrderLineItemDetail( + SalesOrderLineItemMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI +): """API endpoint for detail view of a SalesOrderLineItem object.""" + output_options = SalesOrderLineItemOutputOptions + class SalesOrderExtraLineList(GeneralExtraLineList, ListCreateAPI): """API endpoint for accessing a list of SalesOrderExtraLine objects.""" @@ -1265,11 +1299,26 @@ class SalesOrderAllocationMixin: return queryset -class SalesOrderAllocationList(SalesOrderAllocationMixin, BulkUpdateMixin, ListAPI): +class SalesOrderAllocationOutputOptions(OutputConfiguration): + """Output options for the SalesOrderAllocation endpoint.""" + + OPTIONS = [ + InvenTreeOutputOption('part_detail'), + InvenTreeOutputOption('item_detail'), + InvenTreeOutputOption('order_detail'), + InvenTreeOutputOption('location_detail'), + InvenTreeOutputOption('customer_detail'), + ] + + +class SalesOrderAllocationList( + SalesOrderAllocationMixin, BulkUpdateMixin, OutputOptionsMixin, ListAPI +): """API endpoint for listing SalesOrderAllocation objects.""" filterset_class = SalesOrderAllocationFilter filter_backends = SEARCH_ORDER_FILTER_ALIAS + output_options = SalesOrderAllocationOutputOptions ordering_fields = [ 'quantity', @@ -1299,24 +1348,6 @@ class SalesOrderAllocationList(SalesOrderAllocationMixin, BulkUpdateMixin, ListA 'item__batch', } - def get_serializer(self, *args, **kwargs): - """Return the serializer instance for this endpoint. - - Adds extra detail serializers if requested - """ - try: - params = self.request.query_params - - kwargs['part_detail'] = str2bool(params.get('part_detail', False)) - kwargs['item_detail'] = str2bool(params.get('item_detail', False)) - kwargs['order_detail'] = str2bool(params.get('order_detail', False)) - kwargs['location_detail'] = str2bool(params.get('location_detail', False)) - kwargs['customer_detail'] = str2bool(params.get('customer_detail', False)) - except AttributeError: - pass - - return super().get_serializer(*args, **kwargs) - class SalesOrderAllocationDetail(SalesOrderAllocationMixin, RetrieveUpdateDestroyAPI): """API endpoint for detali view of a SalesOrderAllocation object.""" @@ -1470,13 +1501,6 @@ class ReturnOrderMixin: def get_serializer(self, *args, **kwargs): """Return serializer instance for this endpoint.""" - try: - kwargs['customer_detail'] = str2bool( - self.request.query_params.get('customer_detail', False) - ) - except AttributeError: - pass - # Ensure the context is passed through to the serializer kwargs['context'] = self.get_serializer_context() @@ -1495,8 +1519,18 @@ class ReturnOrderMixin: return queryset +class ReturnOrderOutputOptions(OutputConfiguration): + """Output options for the ReturnOrder endpoint.""" + + OPTIONS = [InvenTreeOutputOption(flag='customer_detail')] + + class ReturnOrderList( - ReturnOrderMixin, OrderCreateMixin, DataExportViewMixin, ListCreateAPI + ReturnOrderMixin, + OrderCreateMixin, + DataExportViewMixin, + OutputOptionsMixin, + ListCreateAPI, ): """API endpoint for accessing a list of ReturnOrder objects.""" @@ -1504,6 +1538,8 @@ class ReturnOrderList( filter_backends = SEARCH_ORDER_FILTER_ALIAS + output_options = ReturnOrderOutputOptions + ordering_field_aliases = { 'reference': ['reference_int', 'reference'], 'project_code': ['project_code__code'], @@ -1534,9 +1570,11 @@ class ReturnOrderList( ordering = '-reference' -class ReturnOrderDetail(ReturnOrderMixin, RetrieveUpdateDestroyAPI): +class ReturnOrderDetail(ReturnOrderMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a single ReturnOrder object.""" + output_options = ReturnOrderOutputOptions + class ReturnOrderContextMixin: """Simple mixin class to add a ReturnOrder to the serializer context.""" @@ -1620,15 +1658,6 @@ class ReturnOrderLineItemMixin: def get_serializer(self, *args, **kwargs): """Return serializer for this endpoint with extra data as requested.""" - try: - params = self.request.query_params - - kwargs['order_detail'] = str2bool(params.get('order_detail', False)) - kwargs['item_detail'] = str2bool(params.get('item_detail', True)) - kwargs['part_detail'] = str2bool(params.get('part_detail', False)) - except AttributeError: - pass - kwargs['context'] = self.get_serializer_context() return super().get_serializer(*args, **kwargs) @@ -1642,8 +1671,18 @@ class ReturnOrderLineItemMixin: return queryset +class ReturnOrderLineItemOutputOptions(OutputConfiguration): + """Output options for the ReturnOrderLineItem endpoint.""" + + OPTIONS = [ + InvenTreeOutputOption('part_detail'), + InvenTreeOutputOption('item_detail', default=True), + InvenTreeOutputOption('order_detail'), + ] + + class ReturnOrderLineItemList( - ReturnOrderLineItemMixin, DataExportViewMixin, ListCreateAPI + ReturnOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI ): """API endpoint for accessing a list of ReturnOrderLineItemList objects.""" @@ -1651,6 +1690,8 @@ class ReturnOrderLineItemList( filter_backends = SEARCH_ORDER_FILTER + output_options = ReturnOrderLineItemOutputOptions + ordering_fields = ['reference', 'target_date', 'received_date'] search_fields = [ @@ -1661,9 +1702,13 @@ class ReturnOrderLineItemList( ] -class ReturnOrderLineItemDetail(ReturnOrderLineItemMixin, RetrieveUpdateDestroyAPI): +class ReturnOrderLineItemDetail( + ReturnOrderLineItemMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI +): """API endpoint for detail view of a ReturnOrderLineItem object.""" + output_options = ReturnOrderLineItemOutputOptions + class ReturnOrderExtraLineList(GeneralExtraLineList, ListCreateAPI): """API endpoint for accessing a list of ReturnOrderExtraLine objects.""" diff --git a/src/backend/InvenTree/order/fixtures/sales_order.yaml b/src/backend/InvenTree/order/fixtures/sales_order.yaml index 4b81c516e6..dfffb25fd4 100644 --- a/src/backend/InvenTree/order/fixtures/sales_order.yaml +++ b/src/backend/InvenTree/order/fixtures/sales_order.yaml @@ -46,6 +46,14 @@ part: 5 quantity: 1 +- model: order.salesorderlineitem + pk: 2 + fields: + order: 4 + part: 5 + quantity: 1 + + # An extra line item - model: order.salesorderextraline pk: 1 @@ -60,3 +68,37 @@ fields: order: 1 reference: "Test Shipment, must be present for metadata test" + +# Allocations for sales orders + +- model: order.salesorderallocation + pk: 1 + fields: + line: 1 + shipment: 1 + item: 1 + quantity: 100 + +- model: order.salesorderallocation + pk: 2 + fields: + line: 1 + shipment: 1 + item: 2 + quantity: 50 + +- model: order.salesorderallocation + pk: 3 + fields: + line: 1 + shipment: null + item: 11 + quantity: 1 + +- model: order.salesorderallocation + pk: 4 + fields: + line: 1 + shipment: 1 + item: 501 + quantity: 1 diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 31d2bdde67..1b559834f0 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -313,6 +313,15 @@ class PurchaseOrderTest(OrderTest): self.assertEqual(response.status_code, status.HTTP_200_OK) + def test_output_options(self): + """Test the various output options for the PurchaseOrder detail endpoint.""" + url = reverse('api-po-detail', kwargs={'pk': 1}) + response = self.get(url, {'supplier_detail': 'true'}, expected_code=200) + self.assertIn('supplier_detail', response.data) + + response = self.get(url, {'supplier_detail': 'false'}, expected_code=200) + self.assertNotIn('supplier_detail', response.data) + def test_po_operations(self): """Test that we can create / edit and delete a PurchaseOrder via the API.""" n = models.PurchaseOrder.objects.count() @@ -852,6 +861,20 @@ class PurchaseOrderLineItemTest(OrderTest): ).json() self.assertEqual(float(li5['purchase_price']), 1) + def test_output_options(self): + """Test PurchaseOrderLineItem output option endpoint.""" + url = reverse('api-po-line-detail', kwargs={'pk': 1}) + + response = self.get(url, {'part_detail': 'true'}, expected_code=200) + self.assertIn('part_detail', response.data) + response = self.get(url, {'part_detail': 'false'}, expected_code=200) + self.assertNotIn('part_detail', response.data) + + response = self.get(url, {'order_detail': 'true'}, expected_code=200) + self.assertIn('order_detail', response.data) + response = self.get(url, {'order_detail': 'false'}, expected_code=200) + self.assertNotIn('order_detail', response.data) + class PurchaseOrderDownloadTest(OrderTest): """Unit tests for downloading PurchaseOrder data via the API endpoint.""" @@ -1749,6 +1772,14 @@ class SalesOrderTest(OrderTest): self.assertIsNotNone(so.shipment_date) self.assertIsNotNone(so.shipped_by) + def test_output_options(self): + """Test the output options for the SalesOrder detail endpoint.""" + url = reverse('api-so-detail', kwargs={'pk': 1}) + response = self.get(url, {'customer_detail': True}, expected_code=200) + self.assertIn('customer_detail', response.data) + response = self.get(url, {'customer_detail': False}, expected_code=200) + self.assertNotIn('customer_detail', response.data) + class SalesOrderLineItemTest(OrderTest): """Tests for the SalesOrderLineItem API.""" @@ -1821,8 +1852,8 @@ class SalesOrderLineItemTest(OrderTest): self.filter({'completed': 0}, n) # Filter by 'allocated' status - self.filter({'allocated': 'true'}, 0) - self.filter({'allocated': 'false'}, n) + self.filter({'allocated': 'true'}, 1) + self.filter({'allocated': 'false'}, n - 1) def test_so_line_allocated_filters(self): """Test filtering by allocation status for a SalesOrderLineItem.""" @@ -1910,6 +1941,17 @@ class SalesOrderLineItemTest(OrderTest): self.filter({'order': order_id, 'completed': 1}, 2) self.filter({'order': order_id, 'completed': 0}, 1) + def test_output_options(self): + """Test the various output options for the SalesOrderLineItem detail endpoint.""" + url = reverse('api-so-line-detail', kwargs={'pk': 1}) + + options = ['part_detail', 'order_detail', 'customer_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) + class SalesOrderDownloadTest(OrderTest): """Unit tests for downloading SalesOrder data via the API endpoint.""" @@ -2252,6 +2294,23 @@ class SalesOrderAllocateTest(OrderTest): len(response.data), count_before + 3 * models.SalesOrder.objects.count() ) + def test_output_options(self): + """Test the various output options for the SalesOrderAllocation detail endpoint.""" + url = reverse('api-so-allocation-list') + + options = [ + 'part_detail', + 'item_detail', + 'order_detail', + 'location_detail', + 'customer_detail', + ] + for option in options: + response = self.get(url, {f'{option}': True}, expected_code=200) + self.assertIn(option, response.data[0]) + response = self.get(url, {f'{option}': False}, expected_code=200) + self.assertNotIn(option, response.data[0]) + class ReturnOrderTests(InvenTreeAPITestCase): """Unit tests for ReturnOrder API endpoints.""" @@ -2618,6 +2677,104 @@ class ReturnOrderTests(InvenTreeAPITestCase): data, required_rows=0, required_cols=['Order', 'Reference', 'Target Date'] ) + def test_output_options(self): + """Test the various output options for the ReturnOrder detail endpoint.""" + url = reverse('api-return-order-detail', kwargs={'pk': 1}) + + options = ['customer_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) + + +class ReturnOrderLineItemTests(InvenTreeAPITestCase): + """Unit tests for ReturnOrderLineItem API endpoints.""" + + fixtures = [ + 'category', + 'company', + 'return_order', + 'part', + 'location', + 'supplier_part', + 'stock', + ] + roles = ['return_order.view'] + + def test_options(self): + """Test the OPTIONS endpoint.""" + self.assignRole('return_order.add') + data = self.options( + reverse('api-return-order-line-list'), expected_code=200 + ).data + + self.assertEqual(data['name'], 'Return Order Line Item List') + + # Check POST fields + post = data['actions']['POST'] + self.assertIn('order', post) + self.assertIn('item', post) + self.assertIn('quantity', post) + self.assertIn('outcome', post) + + def test_list(self): + """Test list endpoint.""" + url = reverse('api-return-order-line-list') + + response = self.get(url, expected_code=200) + self.assertGreater(len(response.data), 0) + + # Test with pagination + data = self.get( + url, {'limit': 1, 'ordering': 'reference'}, expected_code=200 + ).data + + self.assertIn('count', data) + self.assertIn('results', data) + self.assertEqual(len(data['results']), 1) + + def test_detail(self): + """Test detail endpoint.""" + url = reverse('api-return-order-line-detail', kwargs={'pk': 1}) + + response = self.get(url, expected_code=200) + data = response.data + + self.assertIn('order', data) + self.assertIn('item', data) + self.assertIn('quantity', data) + self.assertIn('outcome', data) + + def test_output_options(self): + """Test output options for detail endpoint.""" + url = reverse('api-return-order-line-detail', kwargs={'pk': 1}) + options = ['part_detail', 'item_detail', 'order_detail'] + + for option in options: + # Test with option enabled + response = self.get(url, {option: True}, expected_code=200) + self.assertIn(option, response.data) + + # Test with option disabled + response = self.get(url, {option: False}, expected_code=200) + self.assertNotIn(option, response.data) + + def test_update(self): + """Test updating ReturnOrderLineItem.""" + url = reverse('api-return-order-line-detail', kwargs={'pk': 1}) + + # Without permissions + self.patch(url, {'price': '10.50'}, expected_code=403) + + self.assignRole('return_order.change') + + self.patch(url, {'price': '15.75'}, expected_code=200) + + line = models.ReturnOrderLineItem.objects.get(pk=1) + self.assertEqual(float(line.price.amount), 15.75) + class OrderMetadataAPITest(InvenTreeAPITestCase): """Unit tests for the various metadata endpoints of API."""