diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py
index c55c3d3ba3..34976ffbfe 100644
--- a/InvenTree/InvenTree/api_tester.py
+++ b/InvenTree/InvenTree/api_tester.py
@@ -2,6 +2,11 @@
 Helper functions for performing API unit tests
 """
 
+import csv
+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 +170,87 @@ class InvenTreeAPITestCase(APITestCase):
             self.assertEqual(response.status_code, expected_code)
 
         return response
+
+    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
+        """
+
+        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]
+
+        if expected_fn is not None:
+            self.assertEqual(expected_fn, fn)
+
+        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
+
+    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, 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]
+
+            data.append(entry)
+
+        return data
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/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..6549b1d89d 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
@@ -323,6 +325,77 @@ 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'])
+
+    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):
     """
     Unit tests for receiving items against a PurchaseOrder
@@ -908,6 +981,177 @@ 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"""
+
+    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
+        with self.download_file(
+            url,
+            {
+                'export': 'xls',
+            },
+            expected_code=200,
+            expected_fn='InvenTree_SalesOrders.xls',
+            decode=False,
+        ) as fo:
+            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
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 98e545ee66..df24bdeaae 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):
     """
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})