Merge branch 'master' into devOps

This commit is contained in:
Matthias Mair 2022-05-19 18:28:15 +02:00 committed by GitHub
commit fe4cbe0ad9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 854 additions and 42 deletions

View File

@ -153,6 +153,7 @@ jobs:
invoke delete-data -f invoke delete-data -f
invoke import-fixtures invoke import-fixtures
invoke server -a 127.0.0.1:12345 & invoke server -a 127.0.0.1:12345 &
invoke wait
- name: Run Tests - name: Run Tests
run: | run: |
cd ${{ env.wrapper_name }} cd ${{ env.wrapper_name }}

View File

@ -2,6 +2,11 @@
Helper functions for performing API unit tests 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 import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@ -165,3 +170,87 @@ class InvenTreeAPITestCase(APITestCase):
self.assertEqual(response.status_code, expected_code) self.assertEqual(response.status_code, expected_code)
return response 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

View File

@ -39,7 +39,8 @@ def canAppAccessDatabase(allow_test=False):
'createsuperuser', 'createsuperuser',
'wait_for_db', 'wait_for_db',
'prerender', 'prerender',
'rebuild', 'rebuild_models',
'rebuild_thumbnails',
'collectstatic', 'collectstatic',
'makemessages', 'makemessages',
'compilemessages', 'compilemessages',

View File

@ -1,5 +1,6 @@
import json import json
import os import os
import time
from unittest import mock from unittest import mock
@ -406,11 +407,23 @@ class CurrencyTests(TestCase):
with self.assertRaises(MissingRate): with self.assertRaises(MissingRate):
convert_money(Money(100, 'AUD'), 'USD') 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 # Now that we have some exchange rate information, we can perform conversions

View File

@ -16,7 +16,7 @@ class BuildResource(ModelResource):
# but we don't for other ones. # but we don't for other ones.
# TODO: 2022-05-12 - Need to investigate why this is the case! # TODO: 2022-05-12 - Need to investigate why this is the case!
pk = Field(attribute='pk') id = Field(attribute='pk')
reference = Field(attribute='reference') reference = Field(attribute='reference')
@ -45,6 +45,7 @@ class BuildResource(ModelResource):
clean_model_instances = True clean_model_instances = True
exclude = [ exclude = [
'lft', 'rght', 'tree_id', 'level', 'lft', 'rght', 'tree_id', 'level',
'metadata',
] ]

View File

@ -511,6 +511,50 @@ class BuildTest(BuildAPITest):
self.assertIn('This build output has already been completed', str(response.data)) 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): class BuildAllocationTest(BuildAPITest):
""" """

View File

@ -1111,6 +1111,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': 'SO', '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': { 'PURCHASEORDER_REFERENCE_PREFIX': {
'name': _('Purchase Order Reference Prefix'), 'name': _('Purchase Order Reference Prefix'),
'description': _('Prefix value for purchase order reference'), 'description': _('Prefix value for purchase order reference'),

View File

@ -112,28 +112,61 @@ class SettingsTest(TestCase):
self.assertIn('STOCK_OWNERSHIP_CONTROL', result) self.assertIn('STOCK_OWNERSHIP_CONTROL', result)
self.assertIn('SIGNUP_GROUP', 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 setting has a name, which is translated
- Ensure that every global setting has a description. - 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] for key, setting in InvenTreeUserSetting.SETTINGS.items():
self.run_settings_check(key, setting)
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
def test_defaults(self): def test_defaults(self):
""" """

View File

@ -94,7 +94,6 @@ src="{% static 'img/blank_image.png' %}"
{% else %} {% else %}
<em>{% trans "No manufacturer information available" %}</em> <em>{% trans "No manufacturer information available" %}</em>
{% endif %} {% endif %}
{% endif %}
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@ -55,3 +55,29 @@ class CompanyViewTest(CompanyViewTestBase):
response = self.client.get(reverse('company-index')) response = self.client.get(reverse('company-index'))
self.assertEqual(response.status_code, 200) 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')

View File

@ -105,6 +105,9 @@ class PurchaseOrderResource(ModelResource):
model = PurchaseOrder model = PurchaseOrder
skip_unchanged = True skip_unchanged = True
clean_model_instances = True clean_model_instances = True
exclude = [
'metadata',
]
class PurchaseOrderLineItemResource(ModelResource): class PurchaseOrderLineItemResource(ModelResource):
@ -147,6 +150,9 @@ class SalesOrderResource(ModelResource):
model = SalesOrder model = SalesOrder
skip_unchanged = True skip_unchanged = True
clean_model_instances = True clean_model_instances = True
exclude = [
'metadata',
]
class SalesOrderLineItemResource(ModelResource): class SalesOrderLineItemResource(ModelResource):

View File

@ -667,9 +667,9 @@ class SalesOrderList(APIDownloadMixin, generics.ListCreateAPIView):
outstanding = str2bool(outstanding) outstanding = str2bool(outstanding)
if outstanding: if outstanding:
queryset = queryset.filter(status__in=models.SalesOrderStatus.OPEN) queryset = queryset.filter(status__in=SalesOrderStatus.OPEN)
else: else:
queryset = queryset.exclude(status__in=models.SalesOrderStatus.OPEN) queryset = queryset.exclude(status__in=SalesOrderStatus.OPEN)
# Filter by 'overdue' status # Filter by 'overdue' status
overdue = params.get('overdue', None) overdue = params.get('overdue', None)

View File

@ -12,6 +12,8 @@ from decimal import Decimal
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Q, F, Sum from django.db.models import Q, F, Sum
from django.db.models.functions import Coalesce 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.validators import MinValueValidator
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -809,6 +811,21 @@ class SalesOrder(Order):
return self.pending_shipments().count() 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): class PurchaseOrderAttachment(InvenTreeAttachment):
""" """
Model for storing file attachments against a PurchaseOrder object Model for storing file attachments against a PurchaseOrder object

View File

@ -2,6 +2,8 @@
Tests for the Order API Tests for the Order API
""" """
import io
from datetime import datetime, timedelta from datetime import datetime, timedelta
from rest_framework import status from rest_framework import status
@ -323,6 +325,77 @@ class PurchaseOrderTest(OrderTest):
self.assertEqual(order.get_metadata('yam'), 'yum') 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): class PurchaseOrderReceiveTest(OrderTest):
""" """
Unit tests for receiving items against a PurchaseOrder Unit tests for receiving items against a PurchaseOrder
@ -908,6 +981,177 @@ class SalesOrderTest(OrderTest):
self.assertEqual(order.get_metadata('xyz'), 'abc') 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): class SalesOrderAllocateTest(OrderTest):
""" """
Unit tests for allocating stock items against a SalesOrder Unit tests for allocating stock items against a SalesOrder

View File

@ -10,6 +10,8 @@ from company.models import Company
from InvenTree import status_codes as status from InvenTree import status_codes as status
from common.models import InvenTreeSetting
from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation, SalesOrderShipment from order.models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation, SalesOrderShipment
from part.models import Part from part.models import Part
@ -200,3 +202,37 @@ class SalesOrderTest(TestCase):
self.assertTrue(self.line.is_fully_allocated()) self.assertTrue(self.line.is_fully_allocated())
self.assertEqual(self.line.fulfilled_quantity(), 50) self.assertEqual(self.line.fulfilled_quantity(), 50)
self.assertEqual(self.line.allocated_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)

View File

@ -45,6 +45,7 @@ class PartResource(ModelResource):
exclude = [ exclude = [
'bom_checksum', 'bom_checked_by', 'bom_checked_date', 'bom_checksum', 'bom_checked_by', 'bom_checked_date',
'lft', 'rght', 'tree_id', 'level', 'lft', 'rght', 'tree_id', 'level',
'metadata',
] ]
def get_queryset(self): def get_queryset(self):
@ -98,6 +99,7 @@ class PartCategoryResource(ModelResource):
exclude = [ exclude = [
# Exclude MPTT internal model fields # Exclude MPTT internal model fields
'lft', 'rght', 'tree_id', 'level', 'lft', 'rght', 'tree_id', 'level',
'metadata',
] ]
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs): def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):

View File

@ -822,6 +822,58 @@ class PartAPITest(InvenTreeAPITestCase):
response = self.get('/api/part/10004/', {}) response = self.get('/api/part/10004/', {})
self.assertEqual(response.data['variant_stock'], 500) 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): class PartDetailTests(InvenTreeAPITestCase):
""" """

View File

@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
from maintenance_mode.core import set_maintenance_mode from maintenance_mode.core import set_maintenance_mode
from InvenTree.ready import isImportingData from InvenTree.ready import canAppAccessDatabase
from plugin import registry from plugin import registry
from plugin.helpers import check_git_version, log_error from plugin.helpers import check_git_version, log_error
@ -20,9 +20,8 @@ class PluginAppConfig(AppConfig):
def ready(self): def ready(self):
if settings.PLUGINS_ENABLED: if settings.PLUGINS_ENABLED:
if not canAppAccessDatabase(allow_test=True):
if isImportingData(): # pragma: no cover logger.info("Skipping plugin loading sequence")
logger.info('Skipping plugin loading for data import')
else: else:
logger.info('Loading InvenTree plugins') logger.info('Loading InvenTree plugins')
@ -48,3 +47,6 @@ class PluginAppConfig(AppConfig):
registry.git_is_modern = check_git_version() 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 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') 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")

View File

@ -11,7 +11,8 @@ from django.db.utils import OperationalError, ProgrammingError
import InvenTree.helpers 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.models import PluginConfig, PluginSetting
from plugin.registry import registry from plugin.registry import registry
from plugin.urls import PLUGIN_BASE from plugin.urls import PLUGIN_BASE
@ -59,6 +60,7 @@ class SettingsMixin:
if not plugin: if not plugin:
# Cannot find associated plugin model, return # Cannot find associated plugin model, return
logger.error(f"Plugin configuration not found for plugin '{self.slug}'")
return # pragma: no cover return # pragma: no cover
PluginSetting.set_setting(key, value, user, plugin=plugin) PluginSetting.set_setting(key, value, user, plugin=plugin)
@ -578,10 +580,16 @@ class PanelMixin:
if content_template: if content_template:
# Render content template to HTML # Render content template to HTML
panel['content'] = render_template(self, content_template, ctx) 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: if javascript_template:
# Render javascript template to HTML # Render javascript template to HTML
panel['javascript'] = render_template(self, javascript_template, ctx) 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 # Check for required keys
required_keys = ['title', 'content'] required_keys = ['title', 'content']

View File

@ -2,14 +2,19 @@
from django.test import TestCase from django.test import TestCase
from django.conf import settings 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 import get_user_model
from django.contrib.auth.models import Group
from error_report.models import Error
from plugin import InvenTreePlugin from plugin import InvenTreePlugin
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin
from plugin.urls import PLUGIN_BASE from plugin.urls import PLUGIN_BASE
from plugin.helpers import MixinNotImplementedError from plugin.helpers import MixinNotImplementedError
from plugin.registry import registry
class BaseMixinDefinition: class BaseMixinDefinition:
def test_mixin_name(self): def test_mixin_name(self):
@ -244,3 +249,161 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
# cover wrong token setting # cover wrong token setting
with self.assertRaises(MixinNotImplementedError): with self.assertRaises(MixinNotImplementedError):
self.mixin_wrong2.has_api_call() 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))

View File

@ -245,4 +245,15 @@ def render_template(plugin, template_file, context=None):
html = tmp.render(context) html = tmp.render(context)
return html 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 # endregion

View File

@ -243,7 +243,7 @@ class PluginsRegistry:
# endregion # endregion
# region registry functions # 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 Returns reference to all plugins that have a specified mixin enabled
""" """
@ -251,6 +251,14 @@ class PluginsRegistry:
for plugin in self.plugins.values(): for plugin in self.plugins.values():
if plugin.mixin_enabled(mixin): 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) result.append(plugin)
return result return result

View File

@ -12,7 +12,7 @@ class EventPluginSample(EventMixin, InvenTreePlugin):
""" """
NAME = "EventPlugin" NAME = "EventPlugin"
SLUG = "event" SLUG = "sampleevent"
TITLE = "Triggered Events" TITLE = "Triggered Events"
def process_event(self, event, *args, **kwargs): def process_event(self, event, *args, **kwargs):

View File

@ -15,17 +15,23 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin):
""" """
NAME = "CustomPanelExample" NAME = "CustomPanelExample"
SLUG = "panel" SLUG = "samplepanel"
TITLE = "Custom Panel Example" TITLE = "Custom Panel Example"
DESCRIPTION = "An example plugin demonstrating how custom panels can be added to the user interface" DESCRIPTION = "An example plugin demonstrating how custom panels can be added to the user interface"
VERSION = "0.1" VERSION = "0.1"
SETTINGS = { SETTINGS = {
'ENABLE_HELLO_WORLD': { 'ENABLE_HELLO_WORLD': {
'name': 'Hello World', 'name': 'Enable Hello World',
'description': 'Enable a custom hello world panel on every page', 'description': 'Enable a custom hello world panel on every page',
'default': False, 'default': False,
'validator': bool, '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 = [ panels = [
{ {
# This panel will not be displayed, as it is missing the 'content' key # Simple panel without any actual content
'title': 'No Content', 'title': 'No Content',
} }
] ]
if self.get_setting('ENABLE_HELLO_WORLD'): if self.get_setting('ENABLE_HELLO_WORLD'):
# We can use template rendering in the raw content
content = """
<strong>Hello world!</strong>
<hr>
<div class='alert-alert-block alert-info'>
<em>We can render custom content using the templating system!</em>
</div>
<hr>
<table class='table table-striped'>
<tr><td><strong>Path</strong></td><td>{{ request.path }}</tr>
<tr><td><strong>User</strong></td><td>{{ user.username }}</tr>
</table>
"""
panels.append({ panels.append({
# This 'hello world' panel will be displayed on any view which implements custom panels # This 'hello world' panel will be displayed on any view which implements custom panels
'title': 'Hello World', 'title': 'Hello World',
'icon': 'fas fa-boxes', 'icon': 'fas fa-boxes',
'content': '<b>Hello world!</b>', 'content': content,
'description': 'A simple panel which renders hello world', 'description': 'A simple panel which renders hello world',
'javascript': 'console.log("Hello world, from a custom panel!");', '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 # This panel will *only* display on the PartDetail view
if isinstance(view, PartDetail): if isinstance(view, PartDetail):
panels.append({ panels.append({

View File

@ -1,3 +1,4 @@
import logging
import sys import sys
import traceback import traceback
@ -9,6 +10,9 @@ from error_report.models import Error
from plugin.registry import registry from plugin.registry import registry
logger = logging.getLogger('inventree')
class InvenTreePluginViewMixin: class InvenTreePluginViewMixin:
""" """
Custom view mixin which adds context data to the view, Custom view mixin which adds context data to the view,
@ -25,7 +29,7 @@ class InvenTreePluginViewMixin:
panels = [] panels = []
for plug in registry.with_mixin('panel'): for plug in registry.with_mixin('panel', active=True):
try: try:
panels += plug.render_panels(self, self.request, ctx) panels += plug.render_panels(self, self.request, ctx)
@ -42,6 +46,8 @@ class InvenTreePluginViewMixin:
html=ExceptionReporter(self.request, kind, info, data).get_traceback_html(), 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 return panels
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):

View File

@ -31,6 +31,7 @@ class LocationResource(ModelResource):
exclude = [ exclude = [
# Exclude MPTT internal model fields # Exclude MPTT internal model fields
'lft', 'rght', 'tree_id', 'level', 'lft', 'rght', 'tree_id', 'level',
'metadata',
] ]
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs): def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
@ -119,7 +120,7 @@ class StockItemResource(ModelResource):
# Exclude MPTT internal model fields # Exclude MPTT internal model fields
'lft', 'rght', 'tree_id', 'level', 'lft', 'rght', 'tree_id', 'level',
# Exclude internal fields # Exclude internal fields
'serial_int', 'serial_int', 'metadata',
] ]

View File

@ -344,6 +344,13 @@ class StockItemListTest(StockAPITestCase):
for h in headers: for h in headers:
self.assertIn(h, dataset.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 # Now, add a filter to the results
dataset = self.export_data({'location': 1}) dataset = self.export_data({'location': 1})

View File

@ -12,6 +12,7 @@
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="SALESORDER_REFERENCE_PREFIX" %} {% include "InvenTree/settings/setting.html" with key="SALESORDER_REFERENCE_PREFIX" %}
{% include "InvenTree/settings/setting.html" with key="SALESORDER_DEFAULT_SHIPMENT" icon="fa-truck-loading" %}
</tbody> </tbody>
</table> </table>

View File

@ -138,7 +138,8 @@ function completeShipment(shipment_id) {
$('#so-lines-table').bootstrapTable('refresh'); $('#so-lines-table').bootstrapTable('refresh');
$('#pending-shipments-table').bootstrapTable('refresh'); $('#pending-shipments-table').bootstrapTable('refresh');
$('#completed-shipments-table').bootstrapTable('refresh'); $('#completed-shipments-table').bootstrapTable('refresh');
} },
reload: true
}); });
} }
}); });

View File

@ -17,7 +17,7 @@ ignore =
N812, N812,
# - D415 - First line should end with a period, question mark, or exclamation point # - D415 - First line should end with a period, question mark, or exclamation point
D415, D415,
exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/* exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/*,InvenTree/plugins/*
max-complexity = 20 max-complexity = 20
docstring-convention=google docstring-convention=google