mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into not-working-tests
This commit is contained in:
commit
e1abdddc4a
12
.github/workflows/docker_latest.yaml
vendored
12
.github/workflows/docker_latest.yaml
vendored
@ -18,6 +18,18 @@ jobs:
|
|||||||
- name: Check version number
|
- name: Check version number
|
||||||
run: |
|
run: |
|
||||||
python3 ci/check_version_number.py --dev
|
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
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v1
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
|
37
.github/workflows/docker_test.yaml
vendored
37
.github/workflows/docker_test.yaml
vendored
@ -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
|
|
1
.github/workflows/qc_checks.yaml
vendored
1
.github/workflows/qc_checks.yaml
vendored
@ -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 }}
|
||||||
|
@ -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
|
||||||
|
@ -4,11 +4,14 @@ InvenTree API version information
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# 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
|
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
|
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 'active' status
|
||||||
- Allows filtering of plugin list by 'mixin' support
|
- Allows filtering of plugin list by 'mixin' support
|
||||||
|
@ -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',
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
from test.support import EnvironmentVarGuard
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
import django.core.exceptions as django_exceptions
|
import django.core.exceptions as django_exceptions
|
||||||
@ -404,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
|
||||||
|
|
||||||
@ -449,17 +464,20 @@ class TestSettings(TestCase):
|
|||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.user_mdl = get_user_model()
|
self.user_mdl = get_user_model()
|
||||||
self.env = EnvironmentVarGuard()
|
|
||||||
|
|
||||||
# Create a user for auth
|
# Create a user for auth
|
||||||
user = get_user_model()
|
user = get_user_model()
|
||||||
self.user = user.objects.create_superuser('testuser1', 'test1@testing.com', 'password1')
|
self.user = user.objects.create_superuser('testuser1', 'test1@testing.com', 'password1')
|
||||||
self.client.login(username='testuser1', password='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
|
from plugin import registry
|
||||||
|
|
||||||
with self.env:
|
with self.in_env_context(envs):
|
||||||
settings.USER_ADDED = False
|
settings.USER_ADDED = False
|
||||||
registry.reload_plugins()
|
registry.reload_plugins()
|
||||||
|
|
||||||
@ -475,25 +493,28 @@ class TestSettings(TestCase):
|
|||||||
self.assertEqual(user_count(), 1)
|
self.assertEqual(user_count(), 1)
|
||||||
|
|
||||||
# not enough set
|
# 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)
|
self.assertEqual(user_count(), 1)
|
||||||
|
|
||||||
# enough set
|
# enough set
|
||||||
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
|
self.run_reload({
|
||||||
self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email
|
'INVENTREE_ADMIN_USER': 'admin', # set username
|
||||||
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password
|
'INVENTREE_ADMIN_EMAIL': 'info@example.com', # set email
|
||||||
self.run_reload()
|
'INVENTREE_ADMIN_PASSWORD': 'password123' # set password
|
||||||
|
})
|
||||||
self.assertEqual(user_count(), 2)
|
self.assertEqual(user_count(), 2)
|
||||||
|
|
||||||
# create user manually
|
# create user manually
|
||||||
self.user_mdl.objects.create_user('testuser', 'test@testing.com', 'password')
|
self.user_mdl.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||||
self.assertEqual(user_count(), 3)
|
self.assertEqual(user_count(), 3)
|
||||||
# check it will not be created again
|
# check it will not be created again
|
||||||
self.env.set('INVENTREE_ADMIN_USER', 'testuser')
|
self.run_reload({
|
||||||
self.env.set('INVENTREE_ADMIN_EMAIL', 'test@testing.com')
|
'INVENTREE_ADMIN_USER': 'testuser',
|
||||||
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password')
|
'INVENTREE_ADMIN_EMAIL': 'test@testing.com',
|
||||||
self.run_reload()
|
'INVENTREE_ADMIN_PASSWORD': 'password',
|
||||||
|
})
|
||||||
self.assertEqual(user_count(), 3)
|
self.assertEqual(user_count(), 3)
|
||||||
|
|
||||||
# make sure to clean up
|
# make sure to clean up
|
||||||
@ -517,20 +538,30 @@ class TestSettings(TestCase):
|
|||||||
|
|
||||||
def test_helpers_cfg_file(self):
|
def test_helpers_cfg_file(self):
|
||||||
# normal run - not configured
|
# 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 env set
|
||||||
with self.env:
|
with self.in_env_context({'INVENTREE_CONFIG_FILE': 'my_special_conf.yaml'}):
|
||||||
self.env.set('INVENTREE_CONFIG_FILE', 'my_special_conf.yaml')
|
self.assertIn('inventree/inventree/my_special_conf.yaml', config.get_config_file().lower())
|
||||||
self.assertIn('InvenTree/InvenTree/my_special_conf.yaml', config.get_config_file())
|
|
||||||
|
|
||||||
def test_helpers_plugin_file(self):
|
def test_helpers_plugin_file(self):
|
||||||
# normal run - not configured
|
# 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 env set
|
||||||
with self.env:
|
with self.in_env_context({'INVENTREE_PLUGIN_FILE': 'my_special_plugins.txt'}):
|
||||||
self.env.set('INVENTREE_PLUGIN_FILE', 'my_special_plugins.txt')
|
|
||||||
self.assertIn('my_special_plugins.txt', config.get_plugin_file())
|
self.assertIn('my_special_plugins.txt', config.get_plugin_file())
|
||||||
|
|
||||||
def test_helpers_setting(self):
|
def test_helpers_setting(self):
|
||||||
@ -539,8 +570,7 @@ class TestSettings(TestCase):
|
|||||||
self.assertEqual(config.get_setting(TEST_ENV_NAME, None, '123!'), '123!')
|
self.assertEqual(config.get_setting(TEST_ENV_NAME, None, '123!'), '123!')
|
||||||
|
|
||||||
# with env set
|
# with env set
|
||||||
with self.env:
|
with self.in_env_context({TEST_ENV_NAME: '321'}):
|
||||||
self.env.set(TEST_ENV_NAME, '321')
|
|
||||||
self.assertEqual(config.get_setting(TEST_ENV_NAME, None), '321')
|
self.assertEqual(config.get_setting(TEST_ENV_NAME, None), '321')
|
||||||
|
|
||||||
|
|
||||||
|
@ -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',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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'),
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -8,7 +8,7 @@ import import_export.widgets as widgets
|
|||||||
from .models import Company
|
from .models import Company
|
||||||
from .models import SupplierPart
|
from .models import SupplierPart
|
||||||
from .models import SupplierPriceBreak
|
from .models import SupplierPriceBreak
|
||||||
from .models import ManufacturerPart, ManufacturerPartParameter
|
from .models import ManufacturerPart, ManufacturerPartAttachment, ManufacturerPartParameter
|
||||||
|
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
|
|
||||||
@ -109,6 +109,16 @@ class ManufacturerPartAdmin(ImportExportModelAdmin):
|
|||||||
autocomplete_fields = ('part', 'manufacturer',)
|
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 ManufacturerPartParameterResource(ModelResource):
|
||||||
"""
|
"""
|
||||||
Class for managing ManufacturerPartParameter data import/export
|
Class for managing ManufacturerPartParameter data import/export
|
||||||
@ -175,4 +185,5 @@ admin.site.register(SupplierPart, SupplierPartAdmin)
|
|||||||
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
|
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
|
||||||
|
|
||||||
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
|
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
|
||||||
|
admin.site.register(ManufacturerPartAttachment, ManufacturerPartAttachmentAdmin)
|
||||||
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)
|
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)
|
||||||
|
@ -12,13 +12,14 @@ from django.urls import include, re_path
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from InvenTree.helpers import str2bool
|
from InvenTree.helpers import str2bool
|
||||||
|
from InvenTree.api import AttachmentMixin
|
||||||
|
|
||||||
from .models import Company
|
from .models import Company
|
||||||
from .models import ManufacturerPart, ManufacturerPartParameter
|
from .models import ManufacturerPart, ManufacturerPartAttachment, ManufacturerPartParameter
|
||||||
from .models import SupplierPart, SupplierPriceBreak
|
from .models import SupplierPart, SupplierPriceBreak
|
||||||
|
|
||||||
from .serializers import CompanySerializer
|
from .serializers import CompanySerializer
|
||||||
from .serializers import ManufacturerPartSerializer, ManufacturerPartParameterSerializer
|
from .serializers import ManufacturerPartSerializer, ManufacturerPartAttachmentSerializer, ManufacturerPartParameterSerializer
|
||||||
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
|
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
|
||||||
|
|
||||||
|
|
||||||
@ -160,6 +161,32 @@ class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
serializer_class = ManufacturerPartSerializer
|
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):
|
class ManufacturerPartParameterList(generics.ListCreateAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint for list view of ManufacturerPartParamater model.
|
API endpoint for list view of ManufacturerPartParamater model.
|
||||||
@ -387,6 +414,12 @@ class SupplierPriceBreakDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
manufacturer_part_api_urls = [
|
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'^parameter/', include([
|
||||||
re_path(r'^(?P<pk>\d+)/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'),
|
re_path(r'^(?P<pk>\d+)/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'),
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -22,6 +22,7 @@ from stdimage.models import StdImageField
|
|||||||
|
|
||||||
from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail
|
from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail
|
||||||
from InvenTree.fields import InvenTreeURLField
|
from InvenTree.fields import InvenTreeURLField
|
||||||
|
from InvenTree.models import InvenTreeAttachment
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus
|
from InvenTree.status_codes import PurchaseOrderStatus
|
||||||
|
|
||||||
import InvenTree.validators
|
import InvenTree.validators
|
||||||
@ -380,6 +381,22 @@ class ManufacturerPart(models.Model):
|
|||||||
return s
|
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):
|
class ManufacturerPartParameter(models.Model):
|
||||||
"""
|
"""
|
||||||
A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
|
A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
|
||||||
|
@ -8,6 +8,7 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
from sql_util.utils import SubqueryCount
|
from sql_util.utils import SubqueryCount
|
||||||
|
|
||||||
|
from InvenTree.serializers import InvenTreeAttachmentSerializer
|
||||||
from InvenTree.serializers import InvenTreeDecimalField
|
from InvenTree.serializers import InvenTreeDecimalField
|
||||||
from InvenTree.serializers import InvenTreeImageSerializerField
|
from InvenTree.serializers import InvenTreeImageSerializerField
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer
|
from InvenTree.serializers import InvenTreeModelSerializer
|
||||||
@ -16,7 +17,7 @@ from InvenTree.serializers import InvenTreeMoneySerializer
|
|||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
|
|
||||||
from .models import Company
|
from .models import Company
|
||||||
from .models import ManufacturerPart, ManufacturerPartParameter
|
from .models import ManufacturerPart, ManufacturerPartAttachment, ManufacturerPartParameter
|
||||||
from .models import SupplierPart, SupplierPriceBreak
|
from .models import SupplierPart, SupplierPriceBreak
|
||||||
|
|
||||||
from common.settings import currency_code_default, currency_code_mappings
|
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):
|
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
||||||
"""
|
"""
|
||||||
Serializer for the ManufacturerPartParameter model
|
Serializer for the ManufacturerPartParameter model
|
||||||
|
@ -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>
|
||||||
@ -144,6 +143,21 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
</div>
|
</div>
|
||||||
</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 panel-hidden' id='panel-parameters'>
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
<div class='d-flex flex-wrap'>
|
<div class='d-flex flex-wrap'>
|
||||||
@ -178,6 +192,34 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ 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() {
|
function reloadParameters() {
|
||||||
$("#parameter-table").bootstrapTable("refresh");
|
$("#parameter-table").bootstrapTable("refresh");
|
||||||
}
|
}
|
||||||
|
@ -4,5 +4,7 @@
|
|||||||
|
|
||||||
{% trans "Parameters" as text %}
|
{% trans "Parameters" as text %}
|
||||||
{% include "sidebar_item.html" with label='parameters' text=text icon="fa-th-list" %}
|
{% 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 %}
|
{% trans "Supplier Parts" as text %}
|
||||||
{% include "sidebar_item.html" with label='supplier-parts' text=text icon="fa-building" %}
|
{% include "sidebar_item.html" with label='supplier-parts' text=text icon="fa-building" %}
|
@ -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')
|
||||||
|
@ -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):
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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):
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
@ -1046,24 +1098,29 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertIn('Upload a valid image', str(response.data))
|
||||||
|
|
||||||
# Now try to upload a valid image file
|
# Now try to upload a valid image file, in multiple formats
|
||||||
img = PIL.Image.new('RGB', (128, 128), color='red')
|
for fmt in ['jpg', 'png', 'bmp', 'webp']:
|
||||||
img.save('dummy_image.jpg')
|
fn = f'dummy_image.{fmt}'
|
||||||
|
|
||||||
with open('dummy_image.jpg', 'rb') as dummy_image:
|
img = PIL.Image.new('RGB', (128, 128), color='red')
|
||||||
response = upload_client.patch(
|
img.save(fn)
|
||||||
url,
|
|
||||||
{
|
|
||||||
'image': dummy_image,
|
|
||||||
},
|
|
||||||
format='multipart',
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
self.assertEqual(response.status_code, 200)
|
||||||
p = Part.objects.get(pk=pk)
|
|
||||||
|
# And now check that the image has been set
|
||||||
|
p = Part.objects.get(pk=pk)
|
||||||
|
self.assertIsNotNone(p.image)
|
||||||
|
|
||||||
def test_details(self):
|
def test_details(self):
|
||||||
"""
|
"""
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from allauth.account.models import EmailAddress
|
from allauth.account.models import EmailAddress
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
@ -64,11 +65,21 @@ class TemplateTagTest(TestCase):
|
|||||||
|
|
||||||
def test_hash(self):
|
def test_hash(self):
|
||||||
result_hash = inventree_extras.inventree_commit_hash()
|
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):
|
def test_date(self):
|
||||||
d = inventree_extras.inventree_commit_date()
|
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):
|
def test_github(self):
|
||||||
self.assertIn('github.com', inventree_extras.inventree_github_url())
|
self.assertIn('github.com', inventree_extras.inventree_github_url())
|
||||||
|
@ -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")
|
||||||
|
@ -11,8 +11,10 @@ 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.urls import PLUGIN_BASE
|
from plugin.urls import PLUGIN_BASE
|
||||||
|
|
||||||
|
|
||||||
@ -58,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)
|
||||||
@ -204,7 +207,7 @@ class ScheduleMixin:
|
|||||||
|
|
||||||
Schedule.objects.create(
|
Schedule.objects.create(
|
||||||
name=task_name,
|
name=task_name,
|
||||||
func='plugin.registry.call_function',
|
func=registry.call_plugin_function,
|
||||||
args=f"'{slug}', '{func_name}'",
|
args=f"'{slug}', '{func_name}'",
|
||||||
schedule_type=task['schedule'],
|
schedule_type=task['schedule'],
|
||||||
minutes=task.get('minutes', None),
|
minutes=task.get('minutes', None),
|
||||||
@ -577,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']
|
||||||
|
@ -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))
|
||||||
|
@ -7,7 +7,7 @@ from rest_framework.views import APIView
|
|||||||
|
|
||||||
from InvenTree.tasks import offload_task
|
from InvenTree.tasks import offload_task
|
||||||
|
|
||||||
from plugin import registry
|
from plugin.registry import registry
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
|
|
||||||
|
|
||||||
@ -40,9 +40,6 @@ class LocatePluginView(APIView):
|
|||||||
# StockLocation to identify
|
# StockLocation to identify
|
||||||
location_pk = request.data.get('location', None)
|
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 = {
|
data = {
|
||||||
"success": "Identification plugin activated",
|
"success": "Identification plugin activated",
|
||||||
"plugin": plugin,
|
"plugin": plugin,
|
||||||
@ -53,27 +50,27 @@ class LocatePluginView(APIView):
|
|||||||
try:
|
try:
|
||||||
StockItem.objects.get(pk=item_pk)
|
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
|
data['item'] = item_pk
|
||||||
|
|
||||||
return Response(data)
|
return Response(data)
|
||||||
|
|
||||||
except StockItem.DoesNotExist:
|
except (ValueError, StockItem.DoesNotExist):
|
||||||
raise NotFound("StockItem matching PK '{item}' not found")
|
raise NotFound(f"StockItem matching PK '{item_pk}' not found")
|
||||||
|
|
||||||
elif location_pk:
|
elif location_pk:
|
||||||
try:
|
try:
|
||||||
StockLocation.objects.get(pk=location_pk)
|
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
|
data['location'] = location_pk
|
||||||
|
|
||||||
return Response(data)
|
return Response(data)
|
||||||
|
|
||||||
except StockLocation.DoesNotExist:
|
except (ValueError, StockLocation.DoesNotExist):
|
||||||
raise NotFound("StockLocation matching PK {'location'} not found")
|
raise NotFound(f"StockLocation matching PK '{location_pk}' not found")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise NotFound()
|
raise ParseError("Must supply either 'item' or 'location' parameter")
|
||||||
|
148
InvenTree/plugin/base/locate/test_locate.py
Normal file
148
InvenTree/plugin/base/locate/test_locate.py
Normal 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'])
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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):
|
||||||
|
@ -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({
|
||||||
|
@ -23,7 +23,23 @@ class SampleLocatePlugin(LocateMixin, InvenTreePlugin):
|
|||||||
SLUG = "samplelocate"
|
SLUG = "samplelocate"
|
||||||
TITLE = "Sample plugin for locating items"
|
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):
|
def locate_stock_location(self, location_pk):
|
||||||
|
|
||||||
@ -34,5 +50,9 @@ class SampleLocatePlugin(LocateMixin, InvenTreePlugin):
|
|||||||
try:
|
try:
|
||||||
location = StockLocation.objects.get(pk=location_pk)
|
location = StockLocation.objects.get(pk=location_pk)
|
||||||
logger.info(f"Location exists at '{location.pathstring}'")
|
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!")
|
logger.error(f"Location ID {location_pk} does not exist!")
|
||||||
|
@ -1,9 +1,18 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.views.debug import ExceptionReporter
|
||||||
|
|
||||||
|
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,
|
||||||
@ -20,8 +29,24 @@ class InvenTreePluginViewMixin:
|
|||||||
|
|
||||||
panels = []
|
panels = []
|
||||||
|
|
||||||
for plug in registry.with_mixin('panel'):
|
for plug in registry.with_mixin('panel', active=True):
|
||||||
panels += plug.render_panels(self, self.request, ctx)
|
|
||||||
|
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
|
return panels
|
||||||
|
|
||||||
|
@ -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',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -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})
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -101,6 +101,7 @@ class RuleSet(models.Model):
|
|||||||
'company_supplierpart',
|
'company_supplierpart',
|
||||||
'company_manufacturerpart',
|
'company_manufacturerpart',
|
||||||
'company_manufacturerpartparameter',
|
'company_manufacturerpartparameter',
|
||||||
|
'company_manufacturerpartattachment',
|
||||||
'label_partlabel',
|
'label_partlabel',
|
||||||
],
|
],
|
||||||
'stock_location': [
|
'stock_location': [
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
FROM alpine:3.13 as base
|
FROM alpine:3.14 as base
|
||||||
|
|
||||||
# GitHub source
|
# GitHub source
|
||||||
ARG repository="https://github.com/inventree/InvenTree.git"
|
ARG repository="https://github.com/inventree/InvenTree.git"
|
||||||
@ -62,13 +62,13 @@ RUN apk -U upgrade
|
|||||||
RUN apk add --no-cache git make bash \
|
RUN apk add --no-cache git make bash \
|
||||||
gcc libgcc g++ libstdc++ \
|
gcc libgcc g++ libstdc++ \
|
||||||
gnupg \
|
gnupg \
|
||||||
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev \
|
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev libwebp-dev \
|
||||||
libffi libffi-dev \
|
libffi libffi-dev \
|
||||||
zlib zlib-dev \
|
zlib zlib-dev \
|
||||||
# Special deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement)
|
# Special deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement)
|
||||||
cairo cairo-dev pango pango-dev gdk-pixbuf \
|
cairo cairo-dev pango pango-dev gdk-pixbuf \
|
||||||
# Fonts
|
# 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
|
# Core python
|
||||||
python3 python3-dev py3-pip \
|
python3 python3-dev py3-pip \
|
||||||
# SQLite support
|
# SQLite support
|
||||||
|
@ -39,7 +39,7 @@ inventree # Install the latest version of the Inve
|
|||||||
isort==5.10.1 # DEV: python import sorting
|
isort==5.10.1 # DEV: python import sorting
|
||||||
markdown==3.3.4 # Force particular version of markdown
|
markdown==3.3.4 # Force particular version of markdown
|
||||||
pep8-naming==0.11.1 # PEP naming convention extension
|
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
|
py-moneyed==0.8.0 # Specific version requirement for py-moneyed
|
||||||
pygments==2.7.4 # Syntax highlighting
|
pygments==2.7.4 # Syntax highlighting
|
||||||
python-barcode[images]==0.13.1 # Barcode generator
|
python-barcode[images]==0.13.1 # Barcode generator
|
||||||
|
@ -15,7 +15,7 @@ ignore =
|
|||||||
N806,
|
N806,
|
||||||
# - N812 - lowercase imported as non-lowercase
|
# - N812 - lowercase imported as non-lowercase
|
||||||
N812,
|
N812,
|
||||||
exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/*
|
exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/*,InvenTree/plugins/*
|
||||||
max-complexity = 20
|
max-complexity = 20
|
||||||
|
|
||||||
[coverage:run]
|
[coverage:run]
|
||||||
|
Loading…
Reference in New Issue
Block a user