From fd9f25dc0dd50d59905e0a7ba85f6fd6f15f3d18 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 17 May 2022 21:26:33 +1000
Subject: [PATCH 01/12] Adds helper function for testing a file download via
 the api

---
 InvenTree/InvenTree/api_tester.py | 34 +++++++++++++++++++++++++++++++
 1 file changed, 34 insertions(+)

diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py
index c55c3d3ba3..0420e93369 100644
--- a/InvenTree/InvenTree/api_tester.py
+++ b/InvenTree/InvenTree/api_tester.py
@@ -2,6 +2,10 @@
 Helper functions for performing API unit tests
 """
 
+import io
+import re
+
+from django.http.response import StreamingHttpResponse
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import Group
 from rest_framework.test import APITestCase
@@ -165,3 +169,33 @@ class InvenTreeAPITestCase(APITestCase):
             self.assertEqual(response.status_code, expected_code)
 
         return response
+
+    def download_file(self, url, data, expected_code=None, expected_fn=None):
+        """
+        Download a file from the server, and return an in-memory file
+        """
+
+        response = self.client.get(url, data=data, format='json')
+
+        if expected_code is not None:
+            self.assertEqual(response.status_code, expected_code)
+
+        # Check that the response is of the correct type
+        if not isinstance(response, StreamingHttpResponse):
+            raise ValueError("Response is not a StreamingHttpResponse object as expected")
+        
+        # Extract filename
+        disposition = response.headers['Content-Disposition']
+
+        result = re.search(r'attachment; filename="([\w.]+)"', disposition)
+
+        fn = result.groups()[0]
+
+        with io.BytesIO() as fo:
+            fo.name = fn
+            fo.write(response.getvalue())
+        
+        if expected_fn is not None:
+            self.assertEqual(expected_fn, fn)
+        
+        return fo
\ No newline at end of file

From 55000d5c483f9aded276652e50ad49bfafa50e3a Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 17 May 2022 22:28:46 +1000
Subject: [PATCH 02/12] Add ability to download file as StringIO or BytesIO

---
 InvenTree/InvenTree/api_tester.py | 23 ++++++++++++++++-------
 1 file changed, 16 insertions(+), 7 deletions(-)

diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py
index 0420e93369..122ce1950f 100644
--- a/InvenTree/InvenTree/api_tester.py
+++ b/InvenTree/InvenTree/api_tester.py
@@ -170,7 +170,7 @@ class InvenTreeAPITestCase(APITestCase):
 
         return response
 
-    def download_file(self, url, data, expected_code=None, expected_fn=None):
+    def download_file(self, url, data, expected_code=None, expected_fn=None, decode=False):
         """
         Download a file from the server, and return an in-memory file
         """
@@ -191,11 +191,20 @@ class InvenTreeAPITestCase(APITestCase):
 
         fn = result.groups()[0]
 
-        with io.BytesIO() as fo:
-            fo.name = fn
-            fo.write(response.getvalue())
-        
         if expected_fn is not None:
             self.assertEqual(expected_fn, fn)
-        
-        return fo
\ No newline at end of file
+
+        if decode:
+            # Decode data and return as StringIO file object
+            fo = io.StringIO()
+            fo.name = fo
+            fo.write(response.getvalue().decode('UTF-8'))
+        else:
+            # Return a a BytesIO file object
+            fo = io.BytesIO()
+            fo.name = fn
+            fo.write(response.getvalue())
+
+        fo.seek(0)
+
+        return fo

From 920e7e0bb7dfafec5365d8ddbbdbbe8b482358b9 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 17 May 2022 23:21:30 +1000
Subject: [PATCH 03/12] Adds helper function to process and validate a
 downloaded .csv file

---
 InvenTree/InvenTree/api_tester.py | 35 +++++++++++++++++++++++++++++++
 1 file changed, 35 insertions(+)

diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py
index 122ce1950f..3408322bd3 100644
--- a/InvenTree/InvenTree/api_tester.py
+++ b/InvenTree/InvenTree/api_tester.py
@@ -2,6 +2,7 @@
 Helper functions for performing API unit tests
 """
 
+import csv
 import io
 import re
 
@@ -208,3 +209,37 @@ class InvenTreeAPITestCase(APITestCase):
         fo.seek(0)
 
         return fo
+
+    def process_csv(self, fo, delimiter=',', required_cols=None, excluded_cols=None, required_rows=None):
+        """
+        Helper function to process and validate a downloaded csv file
+        """
+
+        # Check that the correct object type has been passed
+        self.assertTrue(isinstance(fo, io.StringIO))
+
+        fo.seek(0)
+
+        reader = csv.reader(fo, delimiter=delimiter)
+
+        headers = []
+        rows = []
+
+        for idx, row in enumerate(reader):
+            if idx == 0:
+                headers = row
+            else:
+                rows.append(row)
+        
+        if required_cols is not None:
+            for col in required_cols:
+                self.assertIn(col, required_cols)
+            
+        if excluded_cols is not None:
+            for col in excluded_cols:
+                self.assertNotIn(col, excluded_cols)
+        
+        if required_rows is not None:
+            self.assertEqual(len(rows), required_rows)
+        
+        return (headers, rows)

From a44267c306e5f3bec3dc9386022b8d2cd1fae735 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 17 May 2022 23:22:40 +1000
Subject: [PATCH 04/12] bug fix

---
 InvenTree/InvenTree/api_tester.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py
index 3408322bd3..4fb6a9e652 100644
--- a/InvenTree/InvenTree/api_tester.py
+++ b/InvenTree/InvenTree/api_tester.py
@@ -233,11 +233,11 @@ class InvenTreeAPITestCase(APITestCase):
         
         if required_cols is not None:
             for col in required_cols:
-                self.assertIn(col, required_cols)
+                self.assertIn(col, headers)
             
         if excluded_cols is not None:
             for col in excluded_cols:
-                self.assertNotIn(col, excluded_cols)
+                self.assertNotIn(col, headers)
         
         if required_rows is not None:
             self.assertEqual(len(rows), required_rows)

From 3488da19b024b94f27ab8d9f71588fe00cf70eca Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 17 May 2022 23:47:09 +1000
Subject: [PATCH 05/12] Return data as a list of dict objects

---
 InvenTree/InvenTree/api_tester.py | 13 ++++++++++++-
 1 file changed, 12 insertions(+), 1 deletion(-)

diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py
index 4fb6a9e652..1fa2816653 100644
--- a/InvenTree/InvenTree/api_tester.py
+++ b/InvenTree/InvenTree/api_tester.py
@@ -242,4 +242,15 @@ class InvenTreeAPITestCase(APITestCase):
         if required_rows is not None:
             self.assertEqual(len(rows), required_rows)
         
-        return (headers, rows)
+        # Return the file data as a list of dict items, based on the headers
+        data = []
+
+        for row in rows:
+            entry = {}
+        
+            for idx, col in enumerate(headers):
+                entry[col] = row[idx]
+
+            data.append(entry)
+
+        return data

From a6be0b32c6a660c83698c476d0659b991212528e Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 17 May 2022 23:48:58 +1000
Subject: [PATCH 06/12] Add unit tests for exporting SalesOrder data

---
 InvenTree/InvenTree/api_tester.py | 12 ++--
 InvenTree/order/admin.py          |  6 ++
 InvenTree/order/api.py            |  4 +-
 InvenTree/order/test_api.py       | 93 +++++++++++++++++++++++++++++++
 4 files changed, 107 insertions(+), 8 deletions(-)

diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py
index 1fa2816653..9dde7c0f52 100644
--- a/InvenTree/InvenTree/api_tester.py
+++ b/InvenTree/InvenTree/api_tester.py
@@ -184,7 +184,7 @@ class InvenTreeAPITestCase(APITestCase):
         # Check that the response is of the correct type
         if not isinstance(response, StreamingHttpResponse):
             raise ValueError("Response is not a StreamingHttpResponse object as expected")
-        
+
         # Extract filename
         disposition = response.headers['Content-Disposition']
 
@@ -230,24 +230,24 @@ class InvenTreeAPITestCase(APITestCase):
                 headers = row
             else:
                 rows.append(row)
-        
+
         if required_cols is not None:
             for col in required_cols:
                 self.assertIn(col, headers)
-            
+
         if excluded_cols is not None:
             for col in excluded_cols:
                 self.assertNotIn(col, headers)
-        
+
         if required_rows is not None:
             self.assertEqual(len(rows), required_rows)
-        
+
         # Return the file data as a list of dict items, based on the headers
         data = []
 
         for row in rows:
             entry = {}
-        
+
             for idx, col in enumerate(headers):
                 entry[col] = row[idx]
 
diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py
index eaeebff04d..ac74a004e3 100644
--- a/InvenTree/order/admin.py
+++ b/InvenTree/order/admin.py
@@ -105,6 +105,9 @@ class PurchaseOrderResource(ModelResource):
         model = PurchaseOrder
         skip_unchanged = True
         clean_model_instances = True
+        exclude = [
+            'metadata',
+        ]
 
 
 class PurchaseOrderLineItemResource(ModelResource):
@@ -147,6 +150,9 @@ class SalesOrderResource(ModelResource):
         model = SalesOrder
         skip_unchanged = True
         clean_model_instances = True
+        exclude = [
+            'metadata',
+        ]
 
 
 class SalesOrderLineItemResource(ModelResource):
diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py
index e65463d55c..2a9c3b99d2 100644
--- a/InvenTree/order/api.py
+++ b/InvenTree/order/api.py
@@ -667,9 +667,9 @@ class SalesOrderList(APIDownloadMixin, generics.ListCreateAPIView):
             outstanding = str2bool(outstanding)
 
             if outstanding:
-                queryset = queryset.filter(status__in=models.SalesOrderStatus.OPEN)
+                queryset = queryset.filter(status__in=SalesOrderStatus.OPEN)
             else:
-                queryset = queryset.exclude(status__in=models.SalesOrderStatus.OPEN)
+                queryset = queryset.exclude(status__in=SalesOrderStatus.OPEN)
 
         # Filter by 'overdue' status
         overdue = params.get('overdue', None)
diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py
index 76aa8670a4..1a044c9b46 100644
--- a/InvenTree/order/test_api.py
+++ b/InvenTree/order/test_api.py
@@ -2,6 +2,8 @@
 Tests for the Order API
 """
 
+import io
+
 from datetime import datetime, timedelta
 
 from rest_framework import status
@@ -908,6 +910,97 @@ class SalesOrderTest(OrderTest):
         self.assertEqual(order.get_metadata('xyz'), 'abc')
 
 
+class SalesOrderDownloadTest(OrderTest):
+    """Unit tests for downloading SalesOrder data via the API endpoint"""
+
+    def test_download_fail(self):
+        """Test that downloading without the 'export' option fails"""
+
+        url = reverse('api-so-list')
+
+        with self.assertRaises(ValueError):
+            self.download_file(url, {}, expected_code=200)
+
+    def test_download_xls(self):
+        url = reverse('api-so-list')
+
+        # Download .xls file
+        fo = self.download_file(
+            url,
+            {
+                'export': 'xls',
+            },
+            expected_code=200,
+            expected_fn='InvenTree_SalesOrders.xls',
+        )
+
+        self.assertTrue(isinstance(fo, io.BytesIO))
+
+    def test_download_csv(self):
+
+        url = reverse('api-so-list')
+
+        required_cols = [
+            'line_items',
+            'id',
+            'reference',
+            'customer',
+            'status',
+            'shipment_date',
+            'notes',
+            'description',
+        ]
+
+        excluded_cols = [
+            'metadata'
+        ]
+
+        # Download .xls file
+        with self.download_file(
+            url,
+            {
+                'export': 'csv',
+            },
+            expected_code=200,
+            expected_fn='InvenTree_SalesOrders.csv',
+            decode=True
+        ) as fo:
+
+            data = self.process_csv(
+                fo,
+                required_cols=required_cols,
+                excluded_cols=excluded_cols,
+                required_rows=models.SalesOrder.objects.count()
+            )
+
+            for line in data:
+
+                order = models.SalesOrder.objects.get(pk=line['id'])
+
+                self.assertEqual(line['description'], order.description)
+                self.assertEqual(line['status'], str(order.status))
+
+        # Download only outstanding sales orders
+        with self.download_file(
+            url,
+            {
+                'export': 'tsv',
+                'outstanding': True,
+            },
+            expected_code=200,
+            expected_fn='InvenTree_SalesOrders.tsv',
+            decode=True,
+        ) as fo:
+
+            self.process_csv(
+                fo,
+                required_cols=required_cols,
+                excluded_cols=excluded_cols,
+                required_rows=models.SalesOrder.objects.filter(status__in=SalesOrderStatus.OPEN).count(),
+                delimiter='\t',
+            )
+
+
 class SalesOrderAllocateTest(OrderTest):
     """
     Unit tests for allocating stock items against a SalesOrder

From 0d078768feeccc087b9deaefe016d1d7684b1d21 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 18 May 2022 00:08:11 +1000
Subject: [PATCH 07/12] Unit tests for downloading PurchaseOrder data

---
 InvenTree/InvenTree/api_tester.py |  2 +-
 InvenTree/order/test_api.py       | 63 +++++++++++++++++++++++++++++--
 2 files changed, 60 insertions(+), 5 deletions(-)

diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py
index 9dde7c0f52..34976ffbfe 100644
--- a/InvenTree/InvenTree/api_tester.py
+++ b/InvenTree/InvenTree/api_tester.py
@@ -171,7 +171,7 @@ class InvenTreeAPITestCase(APITestCase):
 
         return response
 
-    def download_file(self, url, data, expected_code=None, expected_fn=None, decode=False):
+    def download_file(self, url, data, expected_code=None, expected_fn=None, decode=True):
         """
         Download a file from the server, and return an in-memory file
         """
diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py
index 1a044c9b46..fffeb29216 100644
--- a/InvenTree/order/test_api.py
+++ b/InvenTree/order/test_api.py
@@ -325,6 +325,62 @@ class PurchaseOrderTest(OrderTest):
         self.assertEqual(order.get_metadata('yam'), 'yum')
 
 
+class PurchaseOrderDownloadTest(OrderTest):
+    """Unit tests for downloading PurchaseOrder data via the API endpoint"""
+
+    required_cols = [
+        'id',
+        'line_items',
+        'description',
+        'issue_date',
+        'notes',
+        'reference',
+        'status',
+        'supplier_reference',
+    ]
+
+    excluded_cols = [
+        'metadata',
+    ]
+
+    def test_download_wrong_format(self):
+        """Incorrect format should default raise an error"""
+
+        url = reverse('api-po-list')
+
+        with self.assertRaises(ValueError):
+            self.download_file(
+                url,
+                {
+                    'export': 'xyz',
+                }
+            )
+
+    def test_download_csv(self):
+        """Download PurchaseOrder data as .csv"""
+
+        with self.download_file(
+            reverse('api-po-list'),
+            {
+                'export': 'csv',
+            },
+            expected_code=200,
+            expected_fn='InvenTree_PurchaseOrders.csv',
+        ) as fo:
+
+            data = self.process_csv(
+                fo,
+                required_cols=self.required_cols,
+                excluded_cols=self.excluded_cols,
+                required_rows=models.PurchaseOrder.objects.count()
+            )
+
+            for row in data:
+                order = models.PurchaseOrder.objects.get(pk=row['id'])
+
+                self.assertEqual(order.description, row['description'])
+                self.assertEqual(order.reference, row['reference'])
+
 class PurchaseOrderReceiveTest(OrderTest):
     """
     Unit tests for receiving items against a PurchaseOrder
@@ -925,16 +981,15 @@ class SalesOrderDownloadTest(OrderTest):
         url = reverse('api-so-list')
 
         # Download .xls file
-        fo = self.download_file(
+        with self.download_file(
             url,
             {
                 'export': 'xls',
             },
             expected_code=200,
             expected_fn='InvenTree_SalesOrders.xls',
-        )
-
-        self.assertTrue(isinstance(fo, io.BytesIO))
+        ) as fo:
+            self.assertTrue(isinstance(fo, io.BytesIO))
 
     def test_download_csv(self):
 

From c5b14944a1a710d0afbac5d7d5cc852ad6569fd7 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 18 May 2022 00:31:43 +1000
Subject: [PATCH 08/12] Unit tests for downloading BuildOrder data

---
 InvenTree/build/admin.py    |  3 ++-
 InvenTree/build/test_api.py | 44 +++++++++++++++++++++++++++++++++++++
 InvenTree/order/test_api.py |  1 +
 3 files changed, 47 insertions(+), 1 deletion(-)

diff --git a/InvenTree/build/admin.py b/InvenTree/build/admin.py
index 32d843d057..5988850fe4 100644
--- a/InvenTree/build/admin.py
+++ b/InvenTree/build/admin.py
@@ -16,7 +16,7 @@ class BuildResource(ModelResource):
     # but we don't for other ones.
     # TODO: 2022-05-12 - Need to investigate why this is the case!
 
-    pk = Field(attribute='pk')
+    id = Field(attribute='pk')
 
     reference = Field(attribute='reference')
 
@@ -45,6 +45,7 @@ class BuildResource(ModelResource):
         clean_model_instances = True
         exclude = [
             'lft', 'rght', 'tree_id', 'level',
+            'metadata',
         ]
 
 
diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py
index 068493ad5e..4ba54e9c73 100644
--- a/InvenTree/build/test_api.py
+++ b/InvenTree/build/test_api.py
@@ -511,6 +511,50 @@ class BuildTest(BuildAPITest):
 
         self.assertIn('This build output has already been completed', str(response.data))
 
+    def test_download_build_orders(self):
+
+        required_cols = [
+            'reference',
+            'status',
+            'completed',
+            'batch',
+            'notes',
+            'title',
+            'part',
+            'part_name',
+            'id',
+            'quantity',
+        ]
+
+        excluded_cols = [
+            'lft', 'rght', 'tree_id', 'level',
+            'metadata',
+        ]
+
+        with self.download_file(
+            reverse('api-build-list'),
+            {
+                'export': 'csv',
+            }
+        ) as fo:
+
+            data = self.process_csv(
+                fo,
+                required_cols=required_cols,
+                excluded_cols=excluded_cols,
+                required_rows=Build.objects.count()
+            )
+
+            for row in data:
+
+                build = Build.objects.get(pk=row['id'])
+
+                self.assertEqual(str(build.part.pk), row['part'])
+                self.assertEqual(build.part.full_name, row['part_name'])
+
+                self.assertEqual(build.reference, row['reference'])
+                self.assertEqual(build.title, row['title'])
+
 
 class BuildAllocationTest(BuildAPITest):
     """
diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py
index fffeb29216..bb058d84bb 100644
--- a/InvenTree/order/test_api.py
+++ b/InvenTree/order/test_api.py
@@ -381,6 +381,7 @@ class PurchaseOrderDownloadTest(OrderTest):
                 self.assertEqual(order.description, row['description'])
                 self.assertEqual(order.reference, row['reference'])
 
+
 class PurchaseOrderReceiveTest(OrderTest):
     """
     Unit tests for receiving items against a PurchaseOrder

From 1c6d79451ecfee7c3fc1d53ccb4e0c8bdf404dec Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 18 May 2022 07:25:43 +1000
Subject: [PATCH 09/12] Don't decode downloaded .xls file

---
 InvenTree/order/test_api.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py
index bb058d84bb..04be484310 100644
--- a/InvenTree/order/test_api.py
+++ b/InvenTree/order/test_api.py
@@ -989,6 +989,7 @@ class SalesOrderDownloadTest(OrderTest):
             },
             expected_code=200,
             expected_fn='InvenTree_SalesOrders.xls',
+            deode=False,
         ) as fo:
             self.assertTrue(isinstance(fo, io.BytesIO))
 

From b6c2ade940c814d381c430bab71d840c327d997e Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 18 May 2022 07:52:29 +1000
Subject: [PATCH 10/12] Add unit test for downloading Part data

---
 InvenTree/order/test_api.py |  2 +-
 InvenTree/part/admin.py     |  2 ++
 InvenTree/part/test_api.py  | 52 +++++++++++++++++++++++++++++++++++++
 3 files changed, 55 insertions(+), 1 deletion(-)

diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py
index 04be484310..f06e26370d 100644
--- a/InvenTree/order/test_api.py
+++ b/InvenTree/order/test_api.py
@@ -989,7 +989,7 @@ class SalesOrderDownloadTest(OrderTest):
             },
             expected_code=200,
             expected_fn='InvenTree_SalesOrders.xls',
-            deode=False,
+            decode=False,
         ) as fo:
             self.assertTrue(isinstance(fo, io.BytesIO))
 
diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py
index 8021bd10fa..5fafc37fea 100644
--- a/InvenTree/part/admin.py
+++ b/InvenTree/part/admin.py
@@ -45,6 +45,7 @@ class PartResource(ModelResource):
         exclude = [
             'bom_checksum', 'bom_checked_by', 'bom_checked_date',
             'lft', 'rght', 'tree_id', 'level',
+            'metadata',
         ]
 
     def get_queryset(self):
@@ -98,6 +99,7 @@ class PartCategoryResource(ModelResource):
         exclude = [
             # Exclude MPTT internal model fields
             'lft', 'rght', 'tree_id', 'level',
+            'metadata',
         ]
 
     def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py
index 28df4503c9..46b7a477f8 100644
--- a/InvenTree/part/test_api.py
+++ b/InvenTree/part/test_api.py
@@ -822,6 +822,58 @@ class PartAPITest(InvenTreeAPITestCase):
         response = self.get('/api/part/10004/', {})
         self.assertEqual(response.data['variant_stock'], 500)
 
+    def test_part_download(self):
+        """Test download of part data via the API"""
+
+        url = reverse('api-part-list')
+
+        required_cols = [
+            'id',
+            'name',
+            'description',
+            'in_stock',
+            'category_name',
+            'keywords',
+            'is_template',
+            'virtual',
+            'trackable',
+            'active',
+            'notes',
+            'creation_date',
+        ]
+
+        excluded_cols = [
+            'lft', 'rght', 'level', 'tree_id',
+            'metadata',
+        ]
+
+        with self.download_file(
+            url,
+            {
+                'export': 'csv',
+            },
+            expected_fn='InvenTree_Parts.csv',
+        ) as fo:
+
+            data = self.process_csv(
+                fo,
+                excluded_cols=excluded_cols,
+                required_cols=required_cols,
+                required_rows=Part.objects.count(),
+            )
+
+            for row in data:
+                part = Part.objects.get(pk=row['id'])
+
+                if part.IPN:
+                    self.assertEqual(part.IPN, row['IPN'])
+
+                self.assertEqual(part.name, row['name'])
+                self.assertEqual(part.description, row['description'])
+
+                if part.category:
+                    self.assertEqual(part.category.name, row['category_name'])
+
 
 class PartDetailTests(InvenTreeAPITestCase):
     """

From aa3a86f3728d69aa3132be14513756d3b729fa3b Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 18 May 2022 21:33:40 +1000
Subject: [PATCH 11/12] Exclude metadata from StockLocation and StockItem model
 resource class

---
 InvenTree/stock/admin.py    | 3 ++-
 InvenTree/stock/test_api.py | 7 +++++++
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/InvenTree/stock/admin.py b/InvenTree/stock/admin.py
index 3d931e2d7c..4b7cf38bf5 100644
--- a/InvenTree/stock/admin.py
+++ b/InvenTree/stock/admin.py
@@ -31,6 +31,7 @@ class LocationResource(ModelResource):
         exclude = [
             # Exclude MPTT internal model fields
             'lft', 'rght', 'tree_id', 'level',
+            'metadata',
         ]
 
     def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
@@ -119,7 +120,7 @@ class StockItemResource(ModelResource):
             # Exclude MPTT internal model fields
             'lft', 'rght', 'tree_id', 'level',
             # Exclude internal fields
-            'serial_int',
+            'serial_int', 'metadata',
         ]
 
 
diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py
index 28a8e0de0b..ccdac8d2c6 100644
--- a/InvenTree/stock/test_api.py
+++ b/InvenTree/stock/test_api.py
@@ -344,6 +344,13 @@ class StockItemListTest(StockAPITestCase):
         for h in headers:
             self.assertIn(h, dataset.headers)
 
+        excluded_headers = [
+            'metadata',
+        ]
+
+        for h in excluded_headers:
+            self.assertNotIn(h, dataset.headers)
+
         # Now, add a filter to the results
         dataset = self.export_data({'location': 1})
 

From 0831b85e29cb94f892f2420cec7b7da20282e86e Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 19 May 2022 01:39:16 +1000
Subject: [PATCH 12/12] Adding some unit tests for SalesOrderLineItem API

---
 InvenTree/order/test_api.py | 94 +++++++++++++++++++++++++++++++++++++
 1 file changed, 94 insertions(+)

diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py
index f06e26370d..6549b1d89d 100644
--- a/InvenTree/order/test_api.py
+++ b/InvenTree/order/test_api.py
@@ -381,6 +381,20 @@ class PurchaseOrderDownloadTest(OrderTest):
                 self.assertEqual(order.description, row['description'])
                 self.assertEqual(order.reference, row['reference'])
 
+    def test_download_line_items(self):
+
+        with self.download_file(
+            reverse('api-po-line-list'),
+            {
+                'export': 'xlsx',
+            },
+            decode=False,
+            expected_code=200,
+            expected_fn='InvenTree_PurchaseOrderItems.xlsx',
+        ) as fo:
+
+            self.assertTrue(isinstance(fo, io.BytesIO))
+
 
 class PurchaseOrderReceiveTest(OrderTest):
     """
@@ -967,6 +981,86 @@ class SalesOrderTest(OrderTest):
         self.assertEqual(order.get_metadata('xyz'), 'abc')
 
 
+class SalesOrderLineItemTest(OrderTest):
+    """
+    Tests for the SalesOrderLineItem API
+    """
+
+    def setUp(self):
+
+        super().setUp()
+
+        # List of salable parts
+        parts = Part.objects.filter(salable=True)
+
+        # Create a bunch of SalesOrderLineItems for each order
+        for idx, so in enumerate(models.SalesOrder.objects.all()):
+
+            for part in parts:
+                models.SalesOrderLineItem.objects.create(
+                    order=so,
+                    part=part,
+                    quantity=(idx + 1) * 5,
+                    reference=f"Order {so.reference} - line {idx}",
+                )
+
+        self.url = reverse('api-so-line-list')
+
+    def test_so_line_list(self):
+
+        # List *all* lines
+
+        response = self.get(
+            self.url,
+            {},
+            expected_code=200,
+        )
+
+        n = models.SalesOrderLineItem.objects.count()
+
+        # We should have received *all* lines
+        self.assertEqual(len(response.data), n)
+
+        # List *all* lines, but paginate
+        response = self.get(
+            self.url,
+            {
+                "limit": 5,
+            },
+            expected_code=200,
+        )
+
+        self.assertEqual(response.data['count'], n)
+        self.assertEqual(len(response.data['results']), 5)
+
+        n_orders = models.SalesOrder.objects.count()
+        n_parts = Part.objects.filter(salable=True).count()
+
+        # List by part
+        for part in Part.objects.filter(salable=True):
+            response = self.get(
+                self.url,
+                {
+                    'part': part.pk,
+                    'limit': 10,
+                }
+            )
+
+            self.assertEqual(response.data['count'], n_orders)
+
+        # List by order
+        for order in models.SalesOrder.objects.all():
+            response = self.get(
+                self.url,
+                {
+                    'order': order.pk,
+                    'limit': 10,
+                }
+            )
+
+            self.assertEqual(response.data['count'], n_parts)
+
+
 class SalesOrderDownloadTest(OrderTest):
     """Unit tests for downloading SalesOrder data via the API endpoint"""