diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml
index e73a1e8f98..93b208451b 100644
--- a/.github/workflows/qc_checks.yaml
+++ b/.github/workflows/qc_checks.yaml
@@ -153,6 +153,7 @@ jobs:
invoke delete-data -f
invoke import-fixtures
invoke server -a 127.0.0.1:12345 &
+ invoke wait
- name: Run Tests
run: |
cd ${{ env.wrapper_name }}
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/InvenTree/ready.py b/InvenTree/InvenTree/ready.py
index 9f5ad0ea49..e93972cf2e 100644
--- a/InvenTree/InvenTree/ready.py
+++ b/InvenTree/InvenTree/ready.py
@@ -39,7 +39,8 @@ def canAppAccessDatabase(allow_test=False):
'createsuperuser',
'wait_for_db',
'prerender',
- 'rebuild',
+ 'rebuild_models',
+ 'rebuild_thumbnails',
'collectstatic',
'makemessages',
'compilemessages',
diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py
index 501eed0834..26b50a0eca 100644
--- a/InvenTree/InvenTree/tests.py
+++ b/InvenTree/InvenTree/tests.py
@@ -1,5 +1,6 @@
import json
import os
+import time
from unittest import mock
@@ -406,11 +407,23 @@ class CurrencyTests(TestCase):
with self.assertRaises(MissingRate):
convert_money(Money(100, 'AUD'), 'USD')
- InvenTree.tasks.update_exchange_rates()
+ update_successful = False
- rates = Rate.objects.all()
+ # Note: the update sometimes fails in CI, let's give it a few chances
+ for idx in range(10):
+ InvenTree.tasks.update_exchange_rates()
- self.assertEqual(rates.count(), len(currency_codes()))
+ rates = Rate.objects.all()
+
+ if rates.count() == len(currency_codes()):
+ update_successful = True
+ break
+
+ else:
+ print("Exchange rate update failed - retrying")
+ time.sleep(1)
+
+ self.assertTrue(update_successful)
# Now that we have some exchange rate information, we can perform conversions
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/common/models.py b/InvenTree/common/models.py
index 83773fe48a..92e5c1522c 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -1111,6 +1111,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': 'SO',
},
+ 'SALESORDER_DEFAULT_SHIPMENT': {
+ 'name': _('Sales Order Default Shipment'),
+ 'description': _('Enable creation of default shipment with sales orders'),
+ 'default': False,
+ 'validator': bool,
+ },
+
'PURCHASEORDER_REFERENCE_PREFIX': {
'name': _('Purchase Order Reference Prefix'),
'description': _('Prefix value for purchase order reference'),
diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py
index 8e3f69c21e..7f6f6dbe40 100644
--- a/InvenTree/common/tests.py
+++ b/InvenTree/common/tests.py
@@ -112,28 +112,61 @@ class SettingsTest(TestCase):
self.assertIn('STOCK_OWNERSHIP_CONTROL', result)
self.assertIn('SIGNUP_GROUP', result)
- def test_required_values(self):
+ def run_settings_check(self, key, setting):
+
+ self.assertTrue(type(setting) is dict)
+
+ name = setting.get('name', None)
+
+ self.assertIsNotNone(name)
+ self.assertIn('django.utils.functional.lazy', str(type(name)))
+
+ description = setting.get('description', None)
+
+ self.assertIsNotNone(description)
+ self.assertIn('django.utils.functional.lazy', str(type(description)))
+
+ if key != key.upper():
+ raise ValueError(f"Setting key '{key}' is not uppercase") # pragma: no cover
+
+ # Check that only allowed keys are provided
+ allowed_keys = [
+ 'name',
+ 'description',
+ 'default',
+ 'validator',
+ 'hidden',
+ 'choices',
+ 'units',
+ 'requires_restart',
+ ]
+
+ for k in setting.keys():
+ self.assertIn(k, allowed_keys)
+
+ # Check default value for boolean settings
+ validator = setting.get('validator', None)
+
+ if validator is bool:
+ default = setting.get('default', None)
+
+ # Default value *must* be supplied for boolean setting!
+ self.assertIsNotNone(default)
+
+ # Default value for boolean must itself be a boolean
+ self.assertIn(default, [True, False])
+
+ def test_setting_data(self):
"""
- - Ensure that every global setting has a name.
- - Ensure that every global setting has a description.
+ - Ensure that every setting has a name, which is translated
+ - Ensure that every setting has a description, which is translated
"""
- for key in InvenTreeSetting.SETTINGS.keys():
+ for key, setting in InvenTreeSetting.SETTINGS.items():
+ self.run_settings_check(key, setting)
- setting = InvenTreeSetting.SETTINGS[key]
-
- name = setting.get('name', None)
-
- if name is None:
- raise ValueError(f'Missing GLOBAL_SETTING name for {key}') # pragma: no cover
-
- description = setting.get('description', None)
-
- if description is None:
- raise ValueError(f'Missing GLOBAL_SETTING description for {key}') # pragma: no cover
-
- if key != key.upper():
- raise ValueError(f"SETTINGS key '{key}' is not uppercase") # pragma: no cover
+ for key, setting in InvenTreeUserSetting.SETTINGS.items():
+ self.run_settings_check(key, setting)
def test_defaults(self):
"""
diff --git a/InvenTree/company/templates/company/manufacturer_part.html b/InvenTree/company/templates/company/manufacturer_part.html
index a51ea45099..aa087fe207 100644
--- a/InvenTree/company/templates/company/manufacturer_part.html
+++ b/InvenTree/company/templates/company/manufacturer_part.html
@@ -94,7 +94,6 @@ src="{% static 'img/blank_image.png' %}"
{% else %}
{% trans "No manufacturer information available" %}
{% endif %}
- {% endif %}
diff --git a/InvenTree/company/test_views.py b/InvenTree/company/test_views.py
index 6d28e85d23..29900236bf 100644
--- a/InvenTree/company/test_views.py
+++ b/InvenTree/company/test_views.py
@@ -55,3 +55,29 @@ class CompanyViewTest(CompanyViewTestBase):
response = self.client.get(reverse('company-index'))
self.assertEqual(response.status_code, 200)
+
+ def test_manufacturer_index(self):
+ """ Test the manufacturer index """
+
+ response = self.client.get(reverse('manufacturer-index'))
+ self.assertEqual(response.status_code, 200)
+
+ def test_customer_index(self):
+ """ Test the customer index """
+
+ response = self.client.get(reverse('customer-index'))
+ self.assertEqual(response.status_code, 200)
+
+ def test_manufacturer_part_detail_view(self):
+ """ Test the manufacturer part detail view """
+
+ response = self.client.get(reverse('manufacturer-part-detail', kwargs={'pk': 1}))
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'MPN123')
+
+ def test_supplier_part_detail_view(self):
+ """ Test the supplier part detail view """
+
+ response = self.client.get(reverse('supplier-part-detail', kwargs={'pk': 10}))
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'MPN456-APPEL')
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/models.py b/InvenTree/order/models.py
index 7460e81e56..f4688f3736 100644
--- a/InvenTree/order/models.py
+++ b/InvenTree/order/models.py
@@ -12,6 +12,8 @@ from decimal import Decimal
from django.db import models, transaction
from django.db.models import Q, F, Sum
from django.db.models.functions import Coalesce
+from django.db.models.signals import post_save
+from django.dispatch.dispatcher import receiver
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
@@ -809,6 +811,21 @@ class SalesOrder(Order):
return self.pending_shipments().count()
+@receiver(post_save, sender=SalesOrder, dispatch_uid='build_post_save_log')
+def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs):
+ """
+ Callback function to be executed after a SalesOrder instance is saved
+ """
+ if created and getSetting('SALESORDER_DEFAULT_SHIPMENT'):
+ # A new SalesOrder has just been created
+
+ # Create default shipment
+ SalesOrderShipment.objects.create(
+ order=instance,
+ reference='1',
+ )
+
+
class PurchaseOrderAttachment(InvenTreeAttachment):
"""
Model for storing file attachments against a PurchaseOrder object
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/order/test_sales_order.py b/InvenTree/order/test_sales_order.py
index d43c94996c..cbd572e24d 100644
--- a/InvenTree/order/test_sales_order.py
+++ b/InvenTree/order/test_sales_order.py
@@ -10,6 +10,8 @@ from company.models import Company
from InvenTree import status_codes as status
+from common.models import InvenTreeSetting
+
from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation, SalesOrderShipment
from part.models import Part
@@ -200,3 +202,37 @@ class SalesOrderTest(TestCase):
self.assertTrue(self.line.is_fully_allocated())
self.assertEqual(self.line.fulfilled_quantity(), 50)
self.assertEqual(self.line.allocated_quantity(), 50)
+
+ def test_default_shipment(self):
+ # Test sales order default shipment creation
+
+ # Default setting value should be False
+ self.assertEqual(False, InvenTreeSetting.get_setting('SALESORDER_DEFAULT_SHIPMENT'))
+
+ # Create an order
+ order_1 = SalesOrder.objects.create(
+ customer=self.customer,
+ reference='1235',
+ customer_reference='ABC 55556'
+ )
+
+ # Order should have no shipments when setting is False
+ self.assertEqual(0, order_1.shipment_count)
+
+ # Update setting to True
+ InvenTreeSetting.set_setting('SALESORDER_DEFAULT_SHIPMENT', True, None)
+ self.assertEqual(True, InvenTreeSetting.get_setting('SALESORDER_DEFAULT_SHIPMENT'))
+
+ # Create a second order
+ order_2 = SalesOrder.objects.create(
+ customer=self.customer,
+ reference='1236',
+ customer_reference='ABC 55557'
+ )
+
+ # Order should have one shipment
+ self.assertEqual(1, order_2.shipment_count)
+ self.assertEqual(1, order_2.pending_shipments().count())
+
+ # Shipment should have default reference of '1'
+ self.assertEqual('1', order_2.pending_shipments()[0].reference)
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/plugin/apps.py b/InvenTree/plugin/apps.py
index 233f037ec7..c0e894fef1 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
from maintenance_mode.core import set_maintenance_mode
-from InvenTree.ready import isImportingData
+from InvenTree.ready import canAppAccessDatabase
from plugin import registry
from plugin.helpers import check_git_version, log_error
@@ -20,9 +20,8 @@ class PluginAppConfig(AppConfig):
def ready(self):
if settings.PLUGINS_ENABLED:
-
- if isImportingData(): # pragma: no cover
- logger.info('Skipping plugin loading for data import')
+ if not canAppAccessDatabase(allow_test=True):
+ logger.info("Skipping plugin loading sequence")
else:
logger.info('Loading InvenTree plugins')
@@ -48,3 +47,6 @@ class PluginAppConfig(AppConfig):
registry.git_is_modern = check_git_version()
if not registry.git_is_modern: # pragma: no cover # simulating old git seems not worth it for coverage
log_error(_('Your enviroment has an outdated git version. This prevents InvenTree from loading plugin details.'), 'load')
+
+ else:
+ logger.info("Plugins not enabled - skipping loading sequence")
diff --git a/InvenTree/plugin/base/integration/mixins.py b/InvenTree/plugin/base/integration/mixins.py
index 64de5df22b..6977ef3dd9 100644
--- a/InvenTree/plugin/base/integration/mixins.py
+++ b/InvenTree/plugin/base/integration/mixins.py
@@ -11,7 +11,8 @@ from django.db.utils import OperationalError, ProgrammingError
import InvenTree.helpers
-from plugin.helpers import MixinImplementationError, MixinNotImplementedError, render_template
+from plugin.helpers import MixinImplementationError, MixinNotImplementedError
+from plugin.helpers import render_template, render_text
from plugin.models import PluginConfig, PluginSetting
from plugin.registry import registry
from plugin.urls import PLUGIN_BASE
@@ -59,6 +60,7 @@ class SettingsMixin:
if not plugin:
# Cannot find associated plugin model, return
+ logger.error(f"Plugin configuration not found for plugin '{self.slug}'")
return # pragma: no cover
PluginSetting.set_setting(key, value, user, plugin=plugin)
@@ -578,10 +580,16 @@ class PanelMixin:
if content_template:
# Render content template to HTML
panel['content'] = render_template(self, content_template, ctx)
+ else:
+ # Render content string to HTML
+ panel['content'] = render_text(panel.get('content', ''), ctx)
if javascript_template:
# Render javascript template to HTML
panel['javascript'] = render_template(self, javascript_template, ctx)
+ else:
+ # Render javascript string to HTML
+ panel['javascript'] = render_text(panel.get('javascript', ''), ctx)
# Check for required keys
required_keys = ['title', 'content']
diff --git a/InvenTree/plugin/base/integration/test_mixins.py b/InvenTree/plugin/base/integration/test_mixins.py
index ef3f7062e3..c1afa39fc2 100644
--- a/InvenTree/plugin/base/integration/test_mixins.py
+++ b/InvenTree/plugin/base/integration/test_mixins.py
@@ -2,14 +2,19 @@
from django.test import TestCase
from django.conf import settings
-from django.urls import include, re_path
+from django.urls import include, re_path, reverse
from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
+
+from error_report.models import Error
from plugin import InvenTreePlugin
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin
from plugin.urls import PLUGIN_BASE
from plugin.helpers import MixinNotImplementedError
+from plugin.registry import registry
+
class BaseMixinDefinition:
def test_mixin_name(self):
@@ -244,3 +249,161 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
# cover wrong token setting
with self.assertRaises(MixinNotImplementedError):
self.mixin_wrong2.has_api_call()
+
+
+class PanelMixinTests(TestCase):
+ """Test that the PanelMixin plugin operates correctly"""
+
+ fixtures = [
+ 'category',
+ 'part',
+ 'location',
+ 'stock',
+ ]
+
+ def setUp(self):
+ super().setUp()
+
+ # Create a user which has all the privelages
+ user = get_user_model()
+
+ self.user = user.objects.create_user(
+ username='username',
+ email='user@email.com',
+ password='password'
+ )
+
+ # Put the user into a group with the correct permissions
+ group = Group.objects.create(name='mygroup')
+ self.user.groups.add(group)
+
+ # Give the group *all* the permissions!
+ for rule in group.rule_sets.all():
+ rule.can_view = True
+ rule.can_change = True
+ rule.can_add = True
+ rule.can_delete = True
+
+ rule.save()
+
+ self.client.login(username='username', password='password')
+
+ def test_installed(self):
+ """Test that the sample panel plugin is installed"""
+
+ plugins = registry.with_mixin('panel')
+
+ self.assertTrue(len(plugins) > 0)
+
+ self.assertIn('samplepanel', [p.slug for p in plugins])
+
+ plugins = registry.with_mixin('panel', active=True)
+
+ self.assertEqual(len(plugins), 0)
+
+ def test_disabled(self):
+ """Test that the panels *do not load* if the plugin is not enabled"""
+
+ plugin = registry.get_plugin('samplepanel')
+
+ plugin.set_setting('ENABLE_HELLO_WORLD', True)
+ plugin.set_setting('ENABLE_BROKEN_PANEL', True)
+
+ # Ensure that the plugin is *not* enabled
+ config = plugin.plugin_config()
+
+ self.assertFalse(config.active)
+
+ # Load some pages, ensure that the panel content is *not* loaded
+ for url in [
+ reverse('part-detail', kwargs={'pk': 1}),
+ reverse('stock-item-detail', kwargs={'pk': 2}),
+ reverse('stock-location-detail', kwargs={'pk': 1}),
+ ]:
+ response = self.client.get(
+ url
+ )
+
+ self.assertEqual(response.status_code, 200)
+
+ # Test that these panels have *not* been loaded
+ self.assertNotIn('No Content', str(response.content))
+ self.assertNotIn('Hello world', str(response.content))
+ self.assertNotIn('Custom Part Panel', str(response.content))
+
+ def test_enabled(self):
+ """
+ Test that the panels *do* load if the plugin is enabled
+ """
+
+ plugin = registry.get_plugin('samplepanel')
+
+ self.assertEqual(len(registry.with_mixin('panel', active=True)), 0)
+
+ # Ensure that the plugin is enabled
+ config = plugin.plugin_config()
+ config.active = True
+ config.save()
+
+ self.assertTrue(config.active)
+ self.assertEqual(len(registry.with_mixin('panel', active=True)), 1)
+
+ # Load some pages, ensure that the panel content is *not* loaded
+ urls = [
+ reverse('part-detail', kwargs={'pk': 1}),
+ reverse('stock-item-detail', kwargs={'pk': 2}),
+ reverse('stock-location-detail', kwargs={'pk': 1}),
+ ]
+
+ plugin.set_setting('ENABLE_HELLO_WORLD', False)
+ plugin.set_setting('ENABLE_BROKEN_PANEL', False)
+
+ for url in urls:
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+
+ self.assertIn('No Content', str(response.content))
+
+ # This panel is disabled by plugin setting
+ self.assertNotIn('Hello world!', str(response.content))
+
+ # This panel is only active for the "Part" view
+ if url == urls[0]:
+ self.assertIn('Custom Part Panel', str(response.content))
+ else:
+ self.assertNotIn('Custom Part Panel', str(response.content))
+
+ # Enable the 'Hello World' panel
+ plugin.set_setting('ENABLE_HELLO_WORLD', True)
+
+ for url in urls:
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+
+ self.assertIn('Hello world!', str(response.content))
+
+ # The 'Custom Part' panel should still be there, too
+ if url == urls[0]:
+ self.assertIn('Custom Part Panel', str(response.content))
+ else:
+ self.assertNotIn('Custom Part Panel', str(response.content))
+
+ # Enable the 'broken panel' setting - this will cause all panels to not render
+ plugin.set_setting('ENABLE_BROKEN_PANEL', True)
+
+ n_errors = Error.objects.count()
+
+ for url in urls:
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ # No custom panels should have been loaded
+ self.assertNotIn('No Content', str(response.content))
+ self.assertNotIn('Hello world!', str(response.content))
+ self.assertNotIn('Broken Panel', str(response.content))
+ self.assertNotIn('Custom Part Panel', str(response.content))
+
+ # Assert that each request threw an error
+ self.assertEqual(Error.objects.count(), n_errors + len(urls))
diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py
index 1217fa4d47..90ffe61478 100644
--- a/InvenTree/plugin/helpers.py
+++ b/InvenTree/plugin/helpers.py
@@ -245,4 +245,15 @@ def render_template(plugin, template_file, context=None):
html = tmp.render(context)
return html
+
+
+def render_text(text, context=None):
+ """
+ Locate a raw string with provided context
+ """
+
+ ctx = template.Context(context)
+
+ return template.Template(text).render(ctx)
+
# endregion
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 3d58634340..1ec5adb161 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -243,7 +243,7 @@ class PluginsRegistry:
# endregion
# region registry functions
- def with_mixin(self, mixin: str):
+ def with_mixin(self, mixin: str, active=None):
"""
Returns reference to all plugins that have a specified mixin enabled
"""
@@ -251,6 +251,14 @@ class PluginsRegistry:
for plugin in self.plugins.values():
if plugin.mixin_enabled(mixin):
+
+ if active is not None:
+ # Filter by 'enabled' status
+ config = plugin.plugin_config()
+
+ if config.active != active:
+ continue
+
result.append(plugin)
return result
diff --git a/InvenTree/plugin/samples/event/event_sample.py b/InvenTree/plugin/samples/event/event_sample.py
index 5411781e05..bea21c3ea0 100644
--- a/InvenTree/plugin/samples/event/event_sample.py
+++ b/InvenTree/plugin/samples/event/event_sample.py
@@ -12,7 +12,7 @@ class EventPluginSample(EventMixin, InvenTreePlugin):
"""
NAME = "EventPlugin"
- SLUG = "event"
+ SLUG = "sampleevent"
TITLE = "Triggered Events"
def process_event(self, event, *args, **kwargs):
diff --git a/InvenTree/plugin/samples/integration/custom_panel_sample.py b/InvenTree/plugin/samples/integration/custom_panel_sample.py
index 0203fc4e04..3d44bc0c5b 100644
--- a/InvenTree/plugin/samples/integration/custom_panel_sample.py
+++ b/InvenTree/plugin/samples/integration/custom_panel_sample.py
@@ -15,17 +15,23 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin):
"""
NAME = "CustomPanelExample"
- SLUG = "panel"
+ SLUG = "samplepanel"
TITLE = "Custom Panel Example"
DESCRIPTION = "An example plugin demonstrating how custom panels can be added to the user interface"
VERSION = "0.1"
SETTINGS = {
'ENABLE_HELLO_WORLD': {
- 'name': 'Hello World',
+ 'name': 'Enable Hello World',
'description': 'Enable a custom hello world panel on every page',
'default': False,
'validator': bool,
+ },
+ 'ENABLE_BROKEN_PANEL': {
+ 'name': 'Enable Broken Panel',
+ 'description': 'Enable a panel with rendering issues',
+ 'default': False,
+ 'validator': bool,
}
}
@@ -52,21 +58,48 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin):
panels = [
{
- # This panel will not be displayed, as it is missing the 'content' key
+ # Simple panel without any actual content
'title': 'No Content',
}
]
if self.get_setting('ENABLE_HELLO_WORLD'):
+
+ # We can use template rendering in the raw content
+ content = """
+ Hello world!
+
+
+ We can render custom content using the templating system!
+
+
+
+ Path | {{ request.path }} |
+ User | {{ user.username }} |
+
+ """
+
panels.append({
# This 'hello world' panel will be displayed on any view which implements custom panels
'title': 'Hello World',
'icon': 'fas fa-boxes',
- 'content': 'Hello world!',
+ 'content': content,
'description': 'A simple panel which renders hello world',
'javascript': 'console.log("Hello world, from a custom panel!");',
})
+ if self.get_setting('ENABLE_BROKEN_PANEL'):
+
+ # Enabling this panel will cause panel rendering to break,
+ # due to the invalid tags
+ panels.append({
+ 'title': 'Broken Panel',
+ 'icon': 'fas fa-times-circle',
+ 'content': '{% tag_not_loaded %}',
+ 'description': 'This panel is broken',
+ 'javascript': '{% another_bad_tag %}',
+ })
+
# This panel will *only* display on the PartDetail view
if isinstance(view, PartDetail):
panels.append({
diff --git a/InvenTree/plugin/views.py b/InvenTree/plugin/views.py
index 8d28872695..ad4d54daea 100644
--- a/InvenTree/plugin/views.py
+++ b/InvenTree/plugin/views.py
@@ -1,3 +1,4 @@
+import logging
import sys
import traceback
@@ -9,6 +10,9 @@ from error_report.models import Error
from plugin.registry import registry
+logger = logging.getLogger('inventree')
+
+
class InvenTreePluginViewMixin:
"""
Custom view mixin which adds context data to the view,
@@ -25,7 +29,7 @@ class InvenTreePluginViewMixin:
panels = []
- for plug in registry.with_mixin('panel'):
+ for plug in registry.with_mixin('panel', active=True):
try:
panels += plug.render_panels(self, self.request, ctx)
@@ -42,6 +46,8 @@ class InvenTreePluginViewMixin:
html=ExceptionReporter(self.request, kind, info, data).get_traceback_html(),
)
+ logger.error(f"Plugin '{plug.slug}' could not render custom panels at '{self.request.path}'")
+
return panels
def get_context_data(self, **kwargs):
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})
diff --git a/InvenTree/templates/InvenTree/settings/so.html b/InvenTree/templates/InvenTree/settings/so.html
index e6fde3a093..ac84f5fa86 100644
--- a/InvenTree/templates/InvenTree/settings/so.html
+++ b/InvenTree/templates/InvenTree/settings/so.html
@@ -12,6 +12,7 @@
{% include "InvenTree/settings/setting.html" with key="SALESORDER_REFERENCE_PREFIX" %}
+ {% include "InvenTree/settings/setting.html" with key="SALESORDER_DEFAULT_SHIPMENT" icon="fa-truck-loading" %}
diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js
index e2bee865fd..53dead4b60 100644
--- a/InvenTree/templates/js/translated/order.js
+++ b/InvenTree/templates/js/translated/order.js
@@ -138,7 +138,8 @@ function completeShipment(shipment_id) {
$('#so-lines-table').bootstrapTable('refresh');
$('#pending-shipments-table').bootstrapTable('refresh');
$('#completed-shipments-table').bootstrapTable('refresh');
- }
+ },
+ reload: true
});
}
});
diff --git a/setup.cfg b/setup.cfg
index a1e6fdc530..7fcf9718fe 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -17,7 +17,7 @@ ignore =
N812,
# - D415 - First line should end with a period, question mark, or exclamation point
D415,
-exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/*
+exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/*,InvenTree/plugins/*
max-complexity = 20
docstring-convention=google