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