Merge branch 'inventree:master' into not-working-tests

This commit is contained in:
Matthias Mair 2022-05-20 00:28:35 +02:00 committed by GitHub
commit e1abdddc4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1314 additions and 142 deletions

View File

@ -18,6 +18,18 @@ jobs:
- name: Check version number
run: |
python3 ci/check_version_number.py --dev
- name: Build Docker Image
run: |
cd docker
docker-compose build
docker-compose run inventree-dev-server invoke update
- name: Run unit tests
run: |
cd docker
docker-compose up -d
docker-compose run inventree-dev-server invoke wait
docker-compose run inventree-dev-server invoke test
docker-compose down
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx

View File

@ -1,37 +0,0 @@
# Test that the InvenTree docker image compiles correctly
# This CI action runs on pushes to either the master or stable branches
# 1. Build the development docker image (as per the documentation)
# 2. Install requied python libs into the docker container
# 3. Launch the container
# 4. Check that the API endpoint is available
name: Docker Test
on:
push:
branches:
- 'master'
- 'stable'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Build Docker Image
run: |
cd docker
docker-compose -f docker-compose.sqlite.yml build
docker-compose -f docker-compose.sqlite.yml run inventree-dev-server invoke update
docker-compose -f docker-compose.sqlite.yml up -d
- name: Sleepy Time
run: sleep 60
- name: Test API
run: |
pip install requests
python3 ci/check_api_endpoint.py

View File

@ -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 }}

View File

@ -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

View File

@ -4,11 +4,14 @@ InvenTree API version information
# InvenTree API version
INVENTREE_API_VERSION = 49
INVENTREE_API_VERSION = 50
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v50 -> 2022-05-18 : https://github.com/inventree/InvenTree/pull/2912
- Implement Attachments for manufacturer parts
v49 -> 2022-05-09 : https://github.com/inventree/InvenTree/pull/2957
- Allows filtering of plugin list by 'active' status
- Allows filtering of plugin list by 'mixin' support

View File

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

View File

@ -1,5 +1,8 @@
import json
from test.support import EnvironmentVarGuard
import os
import time
from unittest import mock
from django.test import TestCase, override_settings
import django.core.exceptions as django_exceptions
@ -404,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
@ -449,17 +464,20 @@ class TestSettings(TestCase):
def setUp(self) -> None:
self.user_mdl = get_user_model()
self.env = EnvironmentVarGuard()
# Create a user for auth
user = get_user_model()
self.user = user.objects.create_superuser('testuser1', 'test1@testing.com', 'password1')
self.client.login(username='testuser1', password='password1')
def run_reload(self):
def in_env_context(self, envs={}):
"""Patch the env to include the given dict"""
return mock.patch.dict(os.environ, envs)
def run_reload(self, envs={}):
from plugin import registry
with self.env:
with self.in_env_context(envs):
settings.USER_ADDED = False
registry.reload_plugins()
@ -475,25 +493,28 @@ class TestSettings(TestCase):
self.assertEqual(user_count(), 1)
# not enough set
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
self.run_reload()
self.run_reload({
'INVENTREE_ADMIN_USER': 'admin'
})
self.assertEqual(user_count(), 1)
# enough set
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password
self.run_reload()
self.run_reload({
'INVENTREE_ADMIN_USER': 'admin', # set username
'INVENTREE_ADMIN_EMAIL': 'info@example.com', # set email
'INVENTREE_ADMIN_PASSWORD': 'password123' # set password
})
self.assertEqual(user_count(), 2)
# create user manually
self.user_mdl.objects.create_user('testuser', 'test@testing.com', 'password')
self.assertEqual(user_count(), 3)
# check it will not be created again
self.env.set('INVENTREE_ADMIN_USER', 'testuser')
self.env.set('INVENTREE_ADMIN_EMAIL', 'test@testing.com')
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password')
self.run_reload()
self.run_reload({
'INVENTREE_ADMIN_USER': 'testuser',
'INVENTREE_ADMIN_EMAIL': 'test@testing.com',
'INVENTREE_ADMIN_PASSWORD': 'password',
})
self.assertEqual(user_count(), 3)
# make sure to clean up
@ -517,20 +538,30 @@ class TestSettings(TestCase):
def test_helpers_cfg_file(self):
# normal run - not configured
self.assertIn('InvenTree/InvenTree/config.yaml', config.get_config_file())
valid = [
'inventree/config.yaml',
'inventree/dev/config.yaml',
]
self.assertTrue(any([opt in config.get_config_file().lower() for opt in valid]))
# with env set
with self.env:
self.env.set('INVENTREE_CONFIG_FILE', 'my_special_conf.yaml')
self.assertIn('InvenTree/InvenTree/my_special_conf.yaml', config.get_config_file())
with self.in_env_context({'INVENTREE_CONFIG_FILE': 'my_special_conf.yaml'}):
self.assertIn('inventree/inventree/my_special_conf.yaml', config.get_config_file().lower())
def test_helpers_plugin_file(self):
# normal run - not configured
self.assertIn('InvenTree/InvenTree/plugins.txt', config.get_plugin_file())
valid = [
'inventree/plugins.txt',
'inventree/dev/plugins.txt',
]
self.assertTrue(any([opt in config.get_plugin_file().lower() for opt in valid]))
# with env set
with self.env:
self.env.set('INVENTREE_PLUGIN_FILE', 'my_special_plugins.txt')
with self.in_env_context({'INVENTREE_PLUGIN_FILE': 'my_special_plugins.txt'}):
self.assertIn('my_special_plugins.txt', config.get_plugin_file())
def test_helpers_setting(self):
@ -539,8 +570,7 @@ class TestSettings(TestCase):
self.assertEqual(config.get_setting(TEST_ENV_NAME, None, '123!'), '123!')
# with env set
with self.env:
self.env.set(TEST_ENV_NAME, '321')
with self.in_env_context({TEST_ENV_NAME: '321'}):
self.assertEqual(config.get_setting(TEST_ENV_NAME, None), '321')

View File

@ -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',
]

View File

@ -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):
"""

View File

@ -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'),

View File

@ -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):
"""

View File

@ -8,7 +8,7 @@ import import_export.widgets as widgets
from .models import Company
from .models import SupplierPart
from .models import SupplierPriceBreak
from .models import ManufacturerPart, ManufacturerPartParameter
from .models import ManufacturerPart, ManufacturerPartAttachment, ManufacturerPartParameter
from part.models import Part
@ -109,6 +109,16 @@ class ManufacturerPartAdmin(ImportExportModelAdmin):
autocomplete_fields = ('part', 'manufacturer',)
class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
"""
Admin class for ManufacturerPartAttachment model
"""
list_display = ('manufacturer_part', 'attachment', 'comment')
autocomplete_fields = ('manufacturer_part',)
class ManufacturerPartParameterResource(ModelResource):
"""
Class for managing ManufacturerPartParameter data import/export
@ -175,4 +185,5 @@ admin.site.register(SupplierPart, SupplierPartAdmin)
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
admin.site.register(ManufacturerPartAttachment, ManufacturerPartAttachmentAdmin)
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)

View File

@ -12,13 +12,14 @@ from django.urls import include, re_path
from django.db.models import Q
from InvenTree.helpers import str2bool
from InvenTree.api import AttachmentMixin
from .models import Company
from .models import ManufacturerPart, ManufacturerPartParameter
from .models import ManufacturerPart, ManufacturerPartAttachment, ManufacturerPartParameter
from .models import SupplierPart, SupplierPriceBreak
from .serializers import CompanySerializer
from .serializers import ManufacturerPartSerializer, ManufacturerPartParameterSerializer
from .serializers import ManufacturerPartSerializer, ManufacturerPartAttachmentSerializer, ManufacturerPartParameterSerializer
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
@ -160,6 +161,32 @@ class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = ManufacturerPartSerializer
class ManufacturerPartAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
"""
API endpoint for listing (and creating) a ManufacturerPartAttachment (file upload).
"""
queryset = ManufacturerPartAttachment.objects.all()
serializer_class = ManufacturerPartAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
]
filter_fields = [
'manufacturer_part',
]
class ManufacturerPartAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateDestroyAPIView):
"""
Detail endpooint for ManufacturerPartAttachment model
"""
queryset = ManufacturerPartAttachment.objects.all()
serializer_class = ManufacturerPartAttachmentSerializer
class ManufacturerPartParameterList(generics.ListCreateAPIView):
"""
API endpoint for list view of ManufacturerPartParamater model.
@ -387,6 +414,12 @@ class SupplierPriceBreakDetail(generics.RetrieveUpdateDestroyAPIView):
manufacturer_part_api_urls = [
# Base URL for ManufacturerPartAttachment API endpoints
re_path(r'^attachment/', include([
re_path(r'^(?P<pk>\d+)/', ManufacturerPartAttachmentDetail.as_view(), name='api-manufacturer-part-attachment-detail'),
re_path(r'^$', ManufacturerPartAttachmentList.as_view(), name='api-manufacturer-part-attachment-list'),
])),
re_path(r'^parameter/', include([
re_path(r'^(?P<pk>\d+)/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'),

View File

@ -0,0 +1,33 @@
# Generated by Django 3.2.13 on 2022-05-01 12:57
import InvenTree.fields
import InvenTree.models
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('company', '0042_supplierpricebreak_updated'),
]
operations = [
migrations.CreateModel(
name='ManufacturerPartAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),
('manufacturer_part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='company.manufacturerpart', verbose_name='Manufacturer Part')),
('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'abstract': False,
},
),
]

View File

@ -22,6 +22,7 @@ from stdimage.models import StdImageField
from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail
from InvenTree.fields import InvenTreeURLField
from InvenTree.models import InvenTreeAttachment
from InvenTree.status_codes import PurchaseOrderStatus
import InvenTree.validators
@ -380,6 +381,22 @@ class ManufacturerPart(models.Model):
return s
class ManufacturerPartAttachment(InvenTreeAttachment):
"""
Model for storing file attachments against a ManufacturerPart object
"""
@staticmethod
def get_api_url():
return reverse('api-manufacturer-part-attachment-list')
def getSubdir(self):
return os.path.join("manufacturer_part_files", str(self.manufacturer_part.id))
manufacturer_part = models.ForeignKey(ManufacturerPart, on_delete=models.CASCADE,
verbose_name=_('Manufacturer Part'), related_name='attachments')
class ManufacturerPartParameter(models.Model):
"""
A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.

View File

@ -8,6 +8,7 @@ from rest_framework import serializers
from sql_util.utils import SubqueryCount
from InvenTree.serializers import InvenTreeAttachmentSerializer
from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.serializers import InvenTreeImageSerializerField
from InvenTree.serializers import InvenTreeModelSerializer
@ -16,7 +17,7 @@ from InvenTree.serializers import InvenTreeMoneySerializer
from part.serializers import PartBriefSerializer
from .models import Company
from .models import ManufacturerPart, ManufacturerPartParameter
from .models import ManufacturerPart, ManufacturerPartAttachment, ManufacturerPartParameter
from .models import SupplierPart, SupplierPriceBreak
from common.settings import currency_code_default, currency_code_mappings
@ -142,6 +143,29 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer):
]
class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
"""
Serializer for the ManufacturerPartAttachment class
"""
class Meta:
model = ManufacturerPartAttachment
fields = [
'pk',
'manufacturer_part',
'attachment',
'filename',
'link',
'comment',
'upload_date',
]
read_only_fields = [
'upload_date',
]
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
"""
Serializer for the ManufacturerPartParameter model

View File

@ -94,7 +94,6 @@ src="{% static 'img/blank_image.png' %}"
{% else %}
<em>{% trans "No manufacturer information available" %}</em>
{% endif %}
{% endif %}
</td>
</tr>
<tr>
@ -144,6 +143,21 @@ src="{% static 'img/blank_image.png' %}"
</div>
</div>
<div class='panel panel-hidden' id='panel-attachments'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Attachments" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% include "attachment_button.html" %}
</div>
</div>
</div>
<div class='panel-content'>
{% include "attachment_table.html" %}
</div>
</div>
<div class='panel panel-hidden' id='panel-parameters'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
@ -178,6 +192,34 @@ src="{% static 'img/blank_image.png' %}"
{% block js_ready %}
{{ block.super }}
onPanelLoad("attachments", function() {
loadAttachmentTable('{% url "api-manufacturer-part-attachment-list" %}', {
filters: {
manufacturer_part: {{ part.pk }},
},
fields: {
manufacturer_part: {
value: {{ part.pk }},
hidden: true
}
}
});
enableDragAndDrop(
'#attachment-dropzone',
'{% url "api-manufacturer-part-attachment-list" %}',
{
data: {
manufacturer_part: {{ part.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
reloadAttachmentTable();
}
}
);
});
function reloadParameters() {
$("#parameter-table").bootstrapTable("refresh");
}

View File

@ -4,5 +4,7 @@
{% trans "Parameters" as text %}
{% include "sidebar_item.html" with label='parameters' text=text icon="fa-th-list" %}
{% trans "Attachments" as text %}
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}
{% trans "Supplier Parts" as text %}
{% include "sidebar_item.html" with label='supplier-parts' text=text icon="fa-building" %}

View File

@ -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')

View File

@ -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):

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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):

View File

@ -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):
"""
@ -1046,24 +1098,29 @@ class PartDetailTests(InvenTreeAPITestCase):
)
self.assertEqual(response.status_code, 400)
self.assertIn('Upload a valid image', str(response.data))
# Now try to upload a valid image file
img = PIL.Image.new('RGB', (128, 128), color='red')
img.save('dummy_image.jpg')
# Now try to upload a valid image file, in multiple formats
for fmt in ['jpg', 'png', 'bmp', 'webp']:
fn = f'dummy_image.{fmt}'
with open('dummy_image.jpg', 'rb') as dummy_image:
response = upload_client.patch(
url,
{
'image': dummy_image,
},
format='multipart',
)
img = PIL.Image.new('RGB', (128, 128), color='red')
img.save(fn)
self.assertEqual(response.status_code, 200)
with open(fn, 'rb') as dummy_image:
response = upload_client.patch(
url,
{
'image': dummy_image,
},
format='multipart',
)
# And now check that the image has been set
p = Part.objects.get(pk=pk)
self.assertEqual(response.status_code, 200)
# And now check that the image has been set
p = Part.objects.get(pk=pk)
self.assertIsNotNone(p.image)
def test_details(self):
"""

View File

@ -2,6 +2,7 @@
from allauth.account.models import EmailAddress
from django.conf import settings
from django.contrib.auth import get_user_model
from django.test import TestCase
@ -64,11 +65,21 @@ class TemplateTagTest(TestCase):
def test_hash(self):
result_hash = inventree_extras.inventree_commit_hash()
self.assertGreater(len(result_hash), 5)
if settings.DOCKER:
# Testing inside docker environment *may* return an empty git commit hash
# In such a case, skip this check
pass
else:
self.assertGreater(len(result_hash), 5)
def test_date(self):
d = inventree_extras.inventree_commit_date()
self.assertEqual(len(d.split('-')), 3)
if settings.DOCKER:
# Testing inside docker environment *may* return an empty git commit hash
# In such a case, skip this check
pass
else:
self.assertEqual(len(d.split('-')), 3)
def test_github(self):
self.assertIn('github.com', inventree_extras.inventree_github_url())

View File

@ -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")

View File

@ -11,8 +11,10 @@ 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
@ -58,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)
@ -204,7 +207,7 @@ class ScheduleMixin:
Schedule.objects.create(
name=task_name,
func='plugin.registry.call_function',
func=registry.call_plugin_function,
args=f"'{slug}', '{func_name}'",
schedule_type=task['schedule'],
minutes=task.get('minutes', None),
@ -577,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']

View File

@ -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))

View File

@ -7,7 +7,7 @@ from rest_framework.views import APIView
from InvenTree.tasks import offload_task
from plugin import registry
from plugin.registry import registry
from stock.models import StockItem, StockLocation
@ -40,9 +40,6 @@ class LocatePluginView(APIView):
# StockLocation to identify
location_pk = request.data.get('location', None)
if not item_pk and not location_pk:
raise ParseError("Must supply either 'item' or 'location' parameter")
data = {
"success": "Identification plugin activated",
"plugin": plugin,
@ -53,27 +50,27 @@ class LocatePluginView(APIView):
try:
StockItem.objects.get(pk=item_pk)
offload_task(registry.call_function, plugin, 'locate_stock_item', item_pk)
offload_task(registry.call_plugin_function, plugin, 'locate_stock_item', item_pk)
data['item'] = item_pk
return Response(data)
except StockItem.DoesNotExist:
raise NotFound("StockItem matching PK '{item}' not found")
except (ValueError, StockItem.DoesNotExist):
raise NotFound(f"StockItem matching PK '{item_pk}' not found")
elif location_pk:
try:
StockLocation.objects.get(pk=location_pk)
offload_task(registry.call_function, plugin, 'locate_stock_location', location_pk)
offload_task(registry.call_plugin_function, plugin, 'locate_stock_location', location_pk)
data['location'] = location_pk
return Response(data)
except StockLocation.DoesNotExist:
raise NotFound("StockLocation matching PK {'location'} not found")
except (ValueError, StockLocation.DoesNotExist):
raise NotFound(f"StockLocation matching PK '{location_pk}' not found")
else:
raise NotFound()
raise ParseError("Must supply either 'item' or 'location' parameter")

View File

@ -0,0 +1,148 @@
"""
Unit tests for the 'locate' plugin mixin class
"""
from django.urls import reverse
from InvenTree.api_tester import InvenTreeAPITestCase
from plugin.registry import registry
from stock.models import StockItem, StockLocation
class LocatePluginTests(InvenTreeAPITestCase):
fixtures = [
'category',
'part',
'location',
'stock',
]
def test_installed(self):
"""Test that a locate plugin is actually installed"""
plugins = registry.with_mixin('locate')
self.assertTrue(len(plugins) > 0)
self.assertTrue('samplelocate' in [p.slug for p in plugins])
def test_locate_fail(self):
"""Test various API failure modes"""
url = reverse('api-locate-plugin')
# Post without a plugin
response = self.post(
url,
{},
expected_code=400
)
self.assertIn("'plugin' field must be supplied", str(response.data))
# Post with a plugin that does not exist, or is invalid
for slug in ['xyz', 'event', 'plugin']:
response = self.post(
url,
{
'plugin': slug,
},
expected_code=400,
)
self.assertIn(f"Plugin '{slug}' is not installed, or does not support the location mixin", str(response.data))
# Post with a valid plugin, but no other data
response = self.post(
url,
{
'plugin': 'samplelocate',
},
expected_code=400
)
self.assertIn("Must supply either 'item' or 'location' parameter", str(response.data))
# Post with valid plugin, invalid item or location
for pk in ['qq', 99999, -42]:
response = self.post(
url,
{
'plugin': 'samplelocate',
'item': pk,
},
expected_code=404
)
self.assertIn(f"StockItem matching PK '{pk}' not found", str(response.data))
response = self.post(
url,
{
'plugin': 'samplelocate',
'location': pk,
},
expected_code=404,
)
self.assertIn(f"StockLocation matching PK '{pk}' not found", str(response.data))
def test_locate_item(self):
"""
Test that the plugin correctly 'locates' a StockItem
As the background worker is not running during unit testing,
the sample 'locate' function will be called 'inline'
"""
url = reverse('api-locate-plugin')
item = StockItem.objects.get(pk=1)
# The sample plugin will set the 'located' metadata tag
item.set_metadata('located', False)
response = self.post(
url,
{
'plugin': 'samplelocate',
'item': 1,
},
expected_code=200
)
self.assertEqual(response.data['item'], 1)
item.refresh_from_db()
# Item metadata should have been altered!
self.assertTrue(item.metadata['located'])
def test_locate_location(self):
"""
Test that the plugin correctly 'locates' a StockLocation
"""
url = reverse('api-locate-plugin')
for location in StockLocation.objects.all():
location.set_metadata('located', False)
response = self.post(
url,
{
'plugin': 'samplelocate',
'location': location.pk,
},
expected_code=200
)
self.assertEqual(response.data['location'], location.pk)
location.refresh_from_db()
# Item metadata should have been altered!
self.assertTrue(location.metadata['located'])

View File

@ -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

View File

@ -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

View File

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

View File

@ -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 = """
<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({
# This 'hello world' panel will be displayed on any view which implements custom panels
'title': 'Hello World',
'icon': 'fas fa-boxes',
'content': '<b>Hello world!</b>',
'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({

View File

@ -23,7 +23,23 @@ class SampleLocatePlugin(LocateMixin, InvenTreePlugin):
SLUG = "samplelocate"
TITLE = "Sample plugin for locating items"
VERSION = "0.1"
VERSION = "0.2"
def locate_stock_item(self, item_pk):
from stock.models import StockItem
logger.info(f"SampleLocatePlugin attempting to locate item ID {item_pk}")
try:
item = StockItem.objects.get(pk=item_pk)
logger.info(f"StockItem {item_pk} located!")
# Tag metadata
item.set_metadata('located', True)
except (ValueError, StockItem.DoesNotExist):
logger.error(f"StockItem ID {item_pk} does not exist!")
def locate_stock_location(self, location_pk):
@ -34,5 +50,9 @@ class SampleLocatePlugin(LocateMixin, InvenTreePlugin):
try:
location = StockLocation.objects.get(pk=location_pk)
logger.info(f"Location exists at '{location.pathstring}'")
except StockLocation.DoesNotExist:
# Tag metadata
location.set_metadata('located', True)
except (ValueError, StockLocation.DoesNotExist):
logger.error(f"Location ID {location_pk} does not exist!")

View File

@ -1,9 +1,18 @@
import logging
import sys
import traceback
from django.conf import settings
from django.views.debug import ExceptionReporter
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,
@ -20,8 +29,24 @@ class InvenTreePluginViewMixin:
panels = []
for plug in registry.with_mixin('panel'):
panels += plug.render_panels(self, self.request, ctx)
for plug in registry.with_mixin('panel', active=True):
try:
panels += plug.render_panels(self, self.request, ctx)
except Exception:
# Prevent any plugin error from crashing the page render
kind, info, data = sys.exc_info()
# Log the error to the database
Error.objects.create(
kind=kind.__name__,
info=info,
data='\n'.join(traceback.format_exception(kind, info, data)),
path=self.request.path,
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

View File

@ -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',
]

View File

@ -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})

View File

@ -12,6 +12,7 @@
<table class='table table-striped table-condensed'>
<tbody>
{% 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>
</table>

View File

@ -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
});
}
});

View File

@ -101,6 +101,7 @@ class RuleSet(models.Model):
'company_supplierpart',
'company_manufacturerpart',
'company_manufacturerpartparameter',
'company_manufacturerpartattachment',
'label_partlabel',
],
'stock_location': [

View File

@ -1,4 +1,4 @@
FROM alpine:3.13 as base
FROM alpine:3.14 as base
# GitHub source
ARG repository="https://github.com/inventree/InvenTree.git"
@ -62,13 +62,13 @@ RUN apk -U upgrade
RUN apk add --no-cache git make bash \
gcc libgcc g++ libstdc++ \
gnupg \
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev \
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev libwebp-dev \
libffi libffi-dev \
zlib zlib-dev \
# Special deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement)
cairo cairo-dev pango pango-dev gdk-pixbuf \
# Fonts
fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans ttf-ubuntu-font-family font-croscore font-noto \
fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans font-croscore font-noto \
# Core python
python3 python3-dev py3-pip \
# SQLite support

View File

@ -39,7 +39,7 @@ inventree # Install the latest version of the Inve
isort==5.10.1 # DEV: python import sorting
markdown==3.3.4 # Force particular version of markdown
pep8-naming==0.11.1 # PEP naming convention extension
pillow==9.0.1 # Image manipulation
pillow==9.1.0 # Image manipulation
py-moneyed==0.8.0 # Specific version requirement for py-moneyed
pygments==2.7.4 # Syntax highlighting
python-barcode[images]==0.13.1 # Barcode generator

View File

@ -15,7 +15,7 @@ ignore =
N806,
# - N812 - lowercase imported as non-lowercase
N812,
exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/*
exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/*,InvenTree/plugins/*
max-complexity = 20
[coverage:run]