mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 12:35:46 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 64
|
INVENTREE_API_VERSION = 65
|
||||||
|
|
||||||
"""
|
"""
|
||||||
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
|
||||||
|
|
||||||
|
v65 -> 2022-07-15 : https://github.com/inventree/InvenTree/pull/3335
|
||||||
|
- Annotates 'in_stock' quantity to the SupplierPart API
|
||||||
|
|
||||||
v64 -> 2022-07-08 : https://github.com/inventree/InvenTree/pull/3310
|
v64 -> 2022-07-08 : https://github.com/inventree/InvenTree/pull/3310
|
||||||
- Annotate 'on_order' quantity to BOM list API
|
- Annotate 'on_order' quantity to BOM list API
|
||||||
- Allow BOM List API endpoint to be filtered by "on_order" parameter
|
- Allow BOM List API endpoint to be filtered by "on_order" parameter
|
||||||
|
@ -551,7 +551,7 @@ if "mysql" in db_engine: # pragma: no cover
|
|||||||
# https://docs.djangoproject.com/en/3.2/ref/databases/#mysql-isolation-level
|
# https://docs.djangoproject.com/en/3.2/ref/databases/#mysql-isolation-level
|
||||||
if "isolation_level" not in db_options:
|
if "isolation_level" not in db_options:
|
||||||
serializable = _is_true(
|
serializable = _is_true(
|
||||||
os.getenv("INVENTREE_DB_ISOLATION_SERIALIZABLE", "true")
|
os.getenv("INVENTREE_DB_ISOLATION_SERIALIZABLE", "false")
|
||||||
)
|
)
|
||||||
db_options["isolation_level"] = (
|
db_options["isolation_level"] = (
|
||||||
"serializable" if serializable else "read committed"
|
"serializable" if serializable else "read committed"
|
||||||
@ -744,7 +744,7 @@ if get_setting('TEST_TRANSLATIONS', False): # pragma: no cover
|
|||||||
CURRENCIES = CONFIG.get(
|
CURRENCIES = CONFIG.get(
|
||||||
'currencies',
|
'currencies',
|
||||||
[
|
[
|
||||||
'AUD', 'CAD', 'EUR', 'GBP', 'JPY', 'NZD', 'USD',
|
'AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'NZD', 'USD',
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -24,6 +24,10 @@ def build_refs(apps, schema_editor):
|
|||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
ref = 0
|
ref = 0
|
||||||
|
|
||||||
|
# Clip integer value to ensure it does not overflow database field
|
||||||
|
if ref > 0x7fffffff:
|
||||||
|
ref = 0x7fffffff
|
||||||
|
|
||||||
build.reference_int = ref
|
build.reference_int = ref
|
||||||
build.save()
|
build.save()
|
||||||
|
|
||||||
|
@ -263,6 +263,13 @@ class SupplierPartList(ListCreateDestroyAPIView):
|
|||||||
|
|
||||||
queryset = SupplierPart.objects.all()
|
queryset = SupplierPart.objects.all()
|
||||||
|
|
||||||
|
def get_queryset(self, *args, **kwargs):
|
||||||
|
"""Return annotated queryest object for the SupplierPart list"""
|
||||||
|
queryset = super().get_queryset(*args, **kwargs)
|
||||||
|
queryset = SupplierPartSerializer.annotate_queryset(queryset)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
"""Custom filtering for the queryset."""
|
"""Custom filtering for the queryset."""
|
||||||
queryset = super().filter_queryset(queryset)
|
queryset = super().filter_queryset(queryset)
|
||||||
|
@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from sql_util.utils import SubqueryCount
|
from sql_util.utils import SubqueryCount
|
||||||
|
|
||||||
|
import part.filters
|
||||||
from common.settings import currency_code_default, currency_code_mappings
|
from common.settings import currency_code_default, currency_code_mappings
|
||||||
from InvenTree.serializers import (InvenTreeAttachmentSerializer,
|
from InvenTree.serializers import (InvenTreeAttachmentSerializer,
|
||||||
InvenTreeDecimalField,
|
InvenTreeDecimalField,
|
||||||
@ -199,6 +200,9 @@ class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
|||||||
class SupplierPartSerializer(InvenTreeModelSerializer):
|
class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||||
"""Serializer for SupplierPart object."""
|
"""Serializer for SupplierPart object."""
|
||||||
|
|
||||||
|
# Annotated field showing total in-stock quantity
|
||||||
|
in_stock = serializers.FloatField(read_only=True)
|
||||||
|
|
||||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||||
|
|
||||||
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
|
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
|
||||||
@ -249,6 +253,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
|||||||
'available',
|
'available',
|
||||||
'availability_updated',
|
'availability_updated',
|
||||||
'description',
|
'description',
|
||||||
|
'in_stock',
|
||||||
'link',
|
'link',
|
||||||
'manufacturer',
|
'manufacturer',
|
||||||
'manufacturer_detail',
|
'manufacturer_detail',
|
||||||
@ -270,6 +275,20 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
|||||||
'availability_updated',
|
'availability_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def annotate_queryset(queryset):
|
||||||
|
"""Annotate the SupplierPart queryset with extra fields:
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
in_stock: Current stock quantity for each SupplierPart
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = queryset.annotate(
|
||||||
|
in_stock=part.filters.annotate_total_stock(reference='part__')
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
def update(self, supplier_part, data):
|
def update(self, supplier_part, data):
|
||||||
"""Custom update functionality for the serializer"""
|
"""Custom update functionality for the serializer"""
|
||||||
|
|
||||||
|
@ -61,6 +61,7 @@ base_currency: USD
|
|||||||
currencies:
|
currencies:
|
||||||
- AUD
|
- AUD
|
||||||
- CAD
|
- CAD
|
||||||
|
- CNY
|
||||||
- EUR
|
- EUR
|
||||||
- GBP
|
- GBP
|
||||||
- JPY
|
- JPY
|
||||||
|
@ -23,6 +23,10 @@ def build_refs(apps, schema_editor):
|
|||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
ref = 0
|
ref = 0
|
||||||
|
|
||||||
|
# Clip integer value to ensure it does not overflow database field
|
||||||
|
if ref > 0x7fffffff:
|
||||||
|
ref = 0x7fffffff
|
||||||
|
|
||||||
order.reference_int = ref
|
order.reference_int = ref
|
||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
@ -40,6 +44,10 @@ def build_refs(apps, schema_editor):
|
|||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
ref = 0
|
ref = 0
|
||||||
|
|
||||||
|
# Clip integer value to ensure it does not overflow database field
|
||||||
|
if ref > 0x7fffffff:
|
||||||
|
ref = 0x7fffffff
|
||||||
|
|
||||||
order.reference_int = ref
|
order.reference_int = ref
|
||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
|
@ -49,6 +49,19 @@ class TestRefIntMigrations(MigratorTestCase):
|
|||||||
with self.assertRaises(AttributeError):
|
with self.assertRaises(AttributeError):
|
||||||
print(sales_order.reference_int)
|
print(sales_order.reference_int)
|
||||||
|
|
||||||
|
# Create orders with very large reference values
|
||||||
|
self.po_pk = PurchaseOrder.objects.create(
|
||||||
|
supplier=supplier,
|
||||||
|
reference='999999999999999999999999999999999',
|
||||||
|
description='Big reference field',
|
||||||
|
).pk
|
||||||
|
|
||||||
|
self.so_pk = SalesOrder.objects.create(
|
||||||
|
customer=supplier,
|
||||||
|
reference='999999999999999999999999999999999',
|
||||||
|
description='Big reference field',
|
||||||
|
).pk
|
||||||
|
|
||||||
def test_ref_field(self):
|
def test_ref_field(self):
|
||||||
"""Test that the 'reference_int' field has been created and is filled out correctly."""
|
"""Test that the 'reference_int' field has been created and is filled out correctly."""
|
||||||
PurchaseOrder = self.new_state.apps.get_model('order', 'purchaseorder')
|
PurchaseOrder = self.new_state.apps.get_model('order', 'purchaseorder')
|
||||||
@ -63,6 +76,15 @@ class TestRefIntMigrations(MigratorTestCase):
|
|||||||
self.assertEqual(po.reference_int, ii)
|
self.assertEqual(po.reference_int, ii)
|
||||||
self.assertEqual(so.reference_int, ii)
|
self.assertEqual(so.reference_int, ii)
|
||||||
|
|
||||||
|
# Tests for orders with overly large reference values
|
||||||
|
po = PurchaseOrder.objects.get(pk=self.po_pk)
|
||||||
|
self.assertEqual(po.reference, '999999999999999999999999999999999')
|
||||||
|
self.assertEqual(po.reference_int, 0x7fffffff)
|
||||||
|
|
||||||
|
so = SalesOrder.objects.get(pk=self.so_pk)
|
||||||
|
self.assertEqual(so.reference, '999999999999999999999999999999999')
|
||||||
|
self.assertEqual(so.reference_int, 0x7fffffff)
|
||||||
|
|
||||||
|
|
||||||
class TestShipmentMigration(MigratorTestCase):
|
class TestShipmentMigration(MigratorTestCase):
|
||||||
"""Test data migration for the "SalesOrderShipment" model."""
|
"""Test data migration for the "SalesOrderShipment" model."""
|
||||||
|
@ -206,6 +206,12 @@
|
|||||||
<h4>{% trans "Part Parameters" %}</h4>
|
<h4>{% trans "Part Parameters" %}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class='panel-content'>
|
<div class='panel-content'>
|
||||||
|
<div id='param-button-toolbar'>
|
||||||
|
<div class='btn-group' role='group'>
|
||||||
|
{% include "filter_list.html" with id="parameters" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table class='table table-striped table-condensed' data-toolbar='#param-button-toolbar' id='parametric-part-table'>
|
<table class='table table-striped table-condensed' data-toolbar='#param-button-toolbar' id='parametric-part-table'>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
@ -443,6 +443,10 @@ class APICallMixin:
|
|||||||
if endpoint_is_url:
|
if endpoint_is_url:
|
||||||
url = endpoint
|
url = endpoint
|
||||||
else:
|
else:
|
||||||
|
|
||||||
|
if endpoint.startswith('/'):
|
||||||
|
endpoint = endpoint[1:]
|
||||||
|
|
||||||
url = f'{self.api_url}/{endpoint}'
|
url = f'{self.api_url}/{endpoint}'
|
||||||
|
|
||||||
# build kwargs for call
|
# build kwargs for call
|
||||||
@ -450,6 +454,7 @@ class APICallMixin:
|
|||||||
'url': url,
|
'url': url,
|
||||||
'headers': headers,
|
'headers': headers,
|
||||||
}
|
}
|
||||||
|
|
||||||
if data:
|
if data:
|
||||||
kwargs['data'] = json.dumps(data)
|
kwargs['data'] = json.dumps(data)
|
||||||
|
|
||||||
|
@ -173,6 +173,7 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Setup for all tests."""
|
"""Setup for all tests."""
|
||||||
|
|
||||||
class MixinCls(APICallMixin, SettingsMixin, InvenTreePlugin):
|
class MixinCls(APICallMixin, SettingsMixin, InvenTreePlugin):
|
||||||
NAME = "Sample API Caller"
|
NAME = "Sample API Caller"
|
||||||
|
|
||||||
@ -184,23 +185,32 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
|||||||
'API_URL': {
|
'API_URL': {
|
||||||
'name': 'External URL',
|
'name': 'External URL',
|
||||||
'description': 'Where is your API located?',
|
'description': 'Where is your API located?',
|
||||||
'default': 'reqres.in',
|
'default': 'https://api.github.com',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
API_URL_SETTING = 'API_URL'
|
API_URL_SETTING = 'API_URL'
|
||||||
API_TOKEN_SETTING = 'API_TOKEN'
|
API_TOKEN_SETTING = 'API_TOKEN'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_url(self):
|
||||||
|
"""Override API URL for this test"""
|
||||||
|
return "https://api.github.com"
|
||||||
|
|
||||||
def get_external_url(self, simple: bool = True):
|
def get_external_url(self, simple: bool = True):
|
||||||
"""Returns data from the sample endpoint."""
|
"""Returns data from the sample endpoint."""
|
||||||
return self.api_call('api/users/2', simple_response=simple)
|
return self.api_call('orgs/inventree', simple_response=simple)
|
||||||
|
|
||||||
self.mixin = MixinCls()
|
self.mixin = MixinCls()
|
||||||
|
|
||||||
class WrongCLS(APICallMixin, InvenTreePlugin):
|
class WrongCLS(APICallMixin, InvenTreePlugin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.mixin_wrong = WrongCLS()
|
self.mixin_wrong = WrongCLS()
|
||||||
|
|
||||||
class WrongCLS2(APICallMixin, InvenTreePlugin):
|
class WrongCLS2(APICallMixin, InvenTreePlugin):
|
||||||
API_URL_SETTING = 'test'
|
API_URL_SETTING = 'test'
|
||||||
|
|
||||||
self.mixin_wrong2 = WrongCLS2()
|
self.mixin_wrong2 = WrongCLS2()
|
||||||
|
|
||||||
def test_base_setup(self):
|
def test_base_setup(self):
|
||||||
@ -208,7 +218,7 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
|||||||
# check init
|
# check init
|
||||||
self.assertTrue(self.mixin.has_api_call)
|
self.assertTrue(self.mixin.has_api_call)
|
||||||
# api_url
|
# api_url
|
||||||
self.assertEqual('https://reqres.in', self.mixin.api_url)
|
self.assertEqual('https://api.github.com', self.mixin.api_url)
|
||||||
|
|
||||||
# api_headers
|
# api_headers
|
||||||
headers = self.mixin.api_headers
|
headers = self.mixin.api_headers
|
||||||
@ -232,7 +242,9 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
|||||||
# api_call
|
# api_call
|
||||||
result = self.mixin.get_external_url()
|
result = self.mixin.get_external_url()
|
||||||
self.assertTrue(result)
|
self.assertTrue(result)
|
||||||
self.assertIn('data', result,)
|
|
||||||
|
for key in ['login', 'email', 'name', 'twitter_username']:
|
||||||
|
self.assertIn(key, result)
|
||||||
|
|
||||||
# api_call without json conversion
|
# api_call without json conversion
|
||||||
result = self.mixin.get_external_url(False)
|
result = self.mixin.get_external_url(False)
|
||||||
@ -240,22 +252,22 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
|||||||
self.assertEqual(result.reason, 'OK')
|
self.assertEqual(result.reason, 'OK')
|
||||||
|
|
||||||
# api_call with full url
|
# api_call with full url
|
||||||
result = self.mixin.api_call('https://reqres.in/api/users/2', endpoint_is_url=True)
|
result = self.mixin.api_call('https://api.github.com/orgs/inventree', endpoint_is_url=True)
|
||||||
self.assertTrue(result)
|
self.assertTrue(result)
|
||||||
|
|
||||||
# api_call with post and data
|
# api_call with post and data
|
||||||
result = self.mixin.api_call(
|
result = self.mixin.api_call(
|
||||||
'api/users/',
|
'repos/inventree/InvenTree',
|
||||||
data={"name": "morpheus", "job": "leader"},
|
method='GET'
|
||||||
method='POST'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(result)
|
self.assertTrue(result)
|
||||||
self.assertEqual(result['name'], 'morpheus')
|
self.assertEqual(result['name'], 'InvenTree')
|
||||||
|
self.assertEqual(result['html_url'], 'https://github.com/inventree/InvenTree')
|
||||||
|
|
||||||
# api_call with filter
|
# api_call with filter
|
||||||
result = self.mixin.api_call('api/users', url_args={'page': '2'})
|
result = self.mixin.api_call('repos/inventree/InvenTree/stargazers', url_args={'page': '2'})
|
||||||
self.assertTrue(result)
|
self.assertTrue(result)
|
||||||
self.assertEqual(result['page'], 2)
|
|
||||||
|
|
||||||
def test_function_errors(self):
|
def test_function_errors(self):
|
||||||
"""Test function errors."""
|
"""Test function errors."""
|
||||||
|
@ -28,6 +28,9 @@ def update_serials(apps, schema_editor):
|
|||||||
except Exception:
|
except Exception:
|
||||||
serial = 0
|
serial = 0
|
||||||
|
|
||||||
|
# Ensure the integer value is not too large for the database field
|
||||||
|
if serial > 0x7fffffff:
|
||||||
|
serial = 0x7fffffff
|
||||||
|
|
||||||
item.serial_int = serial
|
item.serial_int = serial
|
||||||
item.save()
|
item.save()
|
||||||
|
69
InvenTree/stock/test_migrations.py
Normal file
69
InvenTree/stock/test_migrations.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"""Unit tests for data migrations in the 'stock' app"""
|
||||||
|
|
||||||
|
from django_test_migrations.contrib.unittest_case import MigratorTestCase
|
||||||
|
|
||||||
|
from InvenTree import helpers
|
||||||
|
|
||||||
|
|
||||||
|
class TestSerialNumberMigration(MigratorTestCase):
|
||||||
|
"""Test data migration which updates serial numbers"""
|
||||||
|
|
||||||
|
migrate_from = ('stock', '0067_alter_stockitem_part')
|
||||||
|
migrate_to = ('stock', helpers.getNewestMigrationFile('stock'))
|
||||||
|
|
||||||
|
def prepare(self):
|
||||||
|
"""Create initial data for this migration"""
|
||||||
|
|
||||||
|
Part = self.old_state.apps.get_model('part', 'part')
|
||||||
|
StockItem = self.old_state.apps.get_model('stock', 'stockitem')
|
||||||
|
|
||||||
|
# Create a base part
|
||||||
|
my_part = Part.objects.create(
|
||||||
|
name='PART-123',
|
||||||
|
description='Some part',
|
||||||
|
active=True,
|
||||||
|
trackable=True,
|
||||||
|
level=0,
|
||||||
|
tree_id=0,
|
||||||
|
lft=0, rght=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create some serialized stock items
|
||||||
|
for sn in range(10, 20):
|
||||||
|
StockItem.objects.create(
|
||||||
|
part=my_part,
|
||||||
|
quantity=1,
|
||||||
|
serial=sn,
|
||||||
|
level=0,
|
||||||
|
tree_id=0,
|
||||||
|
lft=0, rght=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a stock item with a very large serial number
|
||||||
|
item = StockItem.objects.create(
|
||||||
|
part=my_part,
|
||||||
|
quantity=1,
|
||||||
|
serial='9999999999999999999999999999999999999999999999999999999999999',
|
||||||
|
level=0,
|
||||||
|
tree_id=0,
|
||||||
|
lft=0, rght=0
|
||||||
|
)
|
||||||
|
|
||||||
|
self.big_ref_pk = item.pk
|
||||||
|
|
||||||
|
def test_migrations(self):
|
||||||
|
"""Test that the migrations have been applied correctly"""
|
||||||
|
|
||||||
|
StockItem = self.new_state.apps.get_model('stock', 'stockitem')
|
||||||
|
|
||||||
|
# Check that the serial number integer conversion has been applied correctly
|
||||||
|
for sn in range(10, 20):
|
||||||
|
item = StockItem.objects.get(serial_int=sn)
|
||||||
|
|
||||||
|
self.assertEqual(item.serial, str(sn))
|
||||||
|
|
||||||
|
big_ref_item = StockItem.objects.get(pk=self.big_ref_pk)
|
||||||
|
|
||||||
|
# Check that the StockItem maximum serial number
|
||||||
|
self.assertEqual(big_ref_item.serial, '9999999999999999999999999999999999999999999999999999999999999')
|
||||||
|
self.assertEqual(big_ref_item.serial_int, 0x7fffffff)
|
@ -840,6 +840,7 @@ function loadSupplierPartTable(table, url, options) {
|
|||||||
queryParams: filters,
|
queryParams: filters,
|
||||||
name: 'supplierparts',
|
name: 'supplierparts',
|
||||||
groupBy: false,
|
groupBy: false,
|
||||||
|
sortable: true,
|
||||||
formatNoMatches: function() {
|
formatNoMatches: function() {
|
||||||
return '{% trans "No supplier parts found" %}';
|
return '{% trans "No supplier parts found" %}';
|
||||||
},
|
},
|
||||||
@ -957,6 +958,11 @@ function loadSupplierPartTable(table, url, options) {
|
|||||||
title: '{% trans "Packaging" %}',
|
title: '{% trans "Packaging" %}',
|
||||||
sortable: false,
|
sortable: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: 'in_stock',
|
||||||
|
title: '{% trans "In Stock" %}',
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: 'available',
|
field: 'available',
|
||||||
title: '{% trans "Available" %}',
|
title: '{% trans "Available" %}',
|
||||||
|
@ -1198,6 +1198,8 @@ function loadRelatedPartsTable(table, part_id, options={}) {
|
|||||||
*/
|
*/
|
||||||
function loadParametricPartTable(table, options={}) {
|
function loadParametricPartTable(table, options={}) {
|
||||||
|
|
||||||
|
setupFilterList('parameters', $(table), '#filter-list-parameters');
|
||||||
|
|
||||||
var columns = [
|
var columns = [
|
||||||
{
|
{
|
||||||
field: 'name',
|
field: 'name',
|
||||||
|
Reference in New Issue
Block a user