mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-15 11:35:41 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
@ -2,11 +2,14 @@
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
- Annotate 'on_order' quantity to BOM list API
|
||||
- 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
|
||||
if "isolation_level" not in db_options:
|
||||
serializable = _is_true(
|
||||
os.getenv("INVENTREE_DB_ISOLATION_SERIALIZABLE", "true")
|
||||
os.getenv("INVENTREE_DB_ISOLATION_SERIALIZABLE", "false")
|
||||
)
|
||||
db_options["isolation_level"] = (
|
||||
"serializable" if serializable else "read committed"
|
||||
@ -744,7 +744,7 @@ if get_setting('TEST_TRANSLATIONS', False): # pragma: no cover
|
||||
CURRENCIES = CONFIG.get(
|
||||
'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
|
||||
ref = 0
|
||||
|
||||
# Clip integer value to ensure it does not overflow database field
|
||||
if ref > 0x7fffffff:
|
||||
ref = 0x7fffffff
|
||||
|
||||
build.reference_int = ref
|
||||
build.save()
|
||||
|
||||
|
@ -263,6 +263,13 @@ class SupplierPartList(ListCreateDestroyAPIView):
|
||||
|
||||
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):
|
||||
"""Custom filtering for the queryset."""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
from sql_util.utils import SubqueryCount
|
||||
|
||||
import part.filters
|
||||
from common.settings import currency_code_default, currency_code_mappings
|
||||
from InvenTree.serializers import (InvenTreeAttachmentSerializer,
|
||||
InvenTreeDecimalField,
|
||||
@ -199,6 +200,9 @@ class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
||||
class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
"""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)
|
||||
|
||||
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
|
||||
@ -249,6 +253,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
'available',
|
||||
'availability_updated',
|
||||
'description',
|
||||
'in_stock',
|
||||
'link',
|
||||
'manufacturer',
|
||||
'manufacturer_detail',
|
||||
@ -270,6 +275,20 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
'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):
|
||||
"""Custom update functionality for the serializer"""
|
||||
|
||||
|
@ -61,6 +61,7 @@ base_currency: USD
|
||||
currencies:
|
||||
- AUD
|
||||
- CAD
|
||||
- CNY
|
||||
- EUR
|
||||
- GBP
|
||||
- JPY
|
||||
|
@ -23,6 +23,10 @@ def build_refs(apps, schema_editor):
|
||||
except Exception: # pragma: no cover
|
||||
ref = 0
|
||||
|
||||
# Clip integer value to ensure it does not overflow database field
|
||||
if ref > 0x7fffffff:
|
||||
ref = 0x7fffffff
|
||||
|
||||
order.reference_int = ref
|
||||
order.save()
|
||||
|
||||
@ -40,6 +44,10 @@ def build_refs(apps, schema_editor):
|
||||
except Exception: # pragma: no cover
|
||||
ref = 0
|
||||
|
||||
# Clip integer value to ensure it does not overflow database field
|
||||
if ref > 0x7fffffff:
|
||||
ref = 0x7fffffff
|
||||
|
||||
order.reference_int = ref
|
||||
order.save()
|
||||
|
||||
|
@ -49,6 +49,19 @@ class TestRefIntMigrations(MigratorTestCase):
|
||||
with self.assertRaises(AttributeError):
|
||||
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):
|
||||
"""Test that the 'reference_int' field has been created and is filled out correctly."""
|
||||
PurchaseOrder = self.new_state.apps.get_model('order', 'purchaseorder')
|
||||
@ -63,6 +76,15 @@ class TestRefIntMigrations(MigratorTestCase):
|
||||
self.assertEqual(po.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):
|
||||
"""Test data migration for the "SalesOrderShipment" model."""
|
||||
|
@ -206,6 +206,12 @@
|
||||
<h4>{% trans "Part Parameters" %}</h4>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
@ -443,6 +443,10 @@ class APICallMixin:
|
||||
if endpoint_is_url:
|
||||
url = endpoint
|
||||
else:
|
||||
|
||||
if endpoint.startswith('/'):
|
||||
endpoint = endpoint[1:]
|
||||
|
||||
url = f'{self.api_url}/{endpoint}'
|
||||
|
||||
# build kwargs for call
|
||||
@ -450,6 +454,7 @@ class APICallMixin:
|
||||
'url': url,
|
||||
'headers': headers,
|
||||
}
|
||||
|
||||
if data:
|
||||
kwargs['data'] = json.dumps(data)
|
||||
|
||||
|
@ -173,6 +173,7 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||
|
||||
def setUp(self):
|
||||
"""Setup for all tests."""
|
||||
|
||||
class MixinCls(APICallMixin, SettingsMixin, InvenTreePlugin):
|
||||
NAME = "Sample API Caller"
|
||||
|
||||
@ -184,23 +185,32 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||
'API_URL': {
|
||||
'name': 'External URL',
|
||||
'description': 'Where is your API located?',
|
||||
'default': 'reqres.in',
|
||||
'default': 'https://api.github.com',
|
||||
},
|
||||
}
|
||||
|
||||
API_URL_SETTING = 'API_URL'
|
||||
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):
|
||||
"""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()
|
||||
|
||||
class WrongCLS(APICallMixin, InvenTreePlugin):
|
||||
pass
|
||||
|
||||
self.mixin_wrong = WrongCLS()
|
||||
|
||||
class WrongCLS2(APICallMixin, InvenTreePlugin):
|
||||
API_URL_SETTING = 'test'
|
||||
|
||||
self.mixin_wrong2 = WrongCLS2()
|
||||
|
||||
def test_base_setup(self):
|
||||
@ -208,7 +218,7 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||
# check init
|
||||
self.assertTrue(self.mixin.has_api_call)
|
||||
# api_url
|
||||
self.assertEqual('https://reqres.in', self.mixin.api_url)
|
||||
self.assertEqual('https://api.github.com', self.mixin.api_url)
|
||||
|
||||
# api_headers
|
||||
headers = self.mixin.api_headers
|
||||
@ -232,7 +242,9 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||
# api_call
|
||||
result = self.mixin.get_external_url()
|
||||
self.assertTrue(result)
|
||||
self.assertIn('data', result,)
|
||||
|
||||
for key in ['login', 'email', 'name', 'twitter_username']:
|
||||
self.assertIn(key, result)
|
||||
|
||||
# api_call without json conversion
|
||||
result = self.mixin.get_external_url(False)
|
||||
@ -240,22 +252,22 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
|
||||
self.assertEqual(result.reason, 'OK')
|
||||
|
||||
# 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)
|
||||
|
||||
# api_call with post and data
|
||||
result = self.mixin.api_call(
|
||||
'api/users/',
|
||||
data={"name": "morpheus", "job": "leader"},
|
||||
method='POST'
|
||||
'repos/inventree/InvenTree',
|
||||
method='GET'
|
||||
)
|
||||
|
||||
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
|
||||
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.assertEqual(result['page'], 2)
|
||||
|
||||
def test_function_errors(self):
|
||||
"""Test function errors."""
|
||||
|
@ -28,6 +28,9 @@ def update_serials(apps, schema_editor):
|
||||
except Exception:
|
||||
serial = 0
|
||||
|
||||
# Ensure the integer value is not too large for the database field
|
||||
if serial > 0x7fffffff:
|
||||
serial = 0x7fffffff
|
||||
|
||||
item.serial_int = serial
|
||||
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,
|
||||
name: 'supplierparts',
|
||||
groupBy: false,
|
||||
sortable: true,
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No supplier parts found" %}';
|
||||
},
|
||||
@ -957,6 +958,11 @@ function loadSupplierPartTable(table, url, options) {
|
||||
title: '{% trans "Packaging" %}',
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
field: 'in_stock',
|
||||
title: '{% trans "In Stock" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'available',
|
||||
title: '{% trans "Available" %}',
|
||||
|
@ -1198,6 +1198,8 @@ function loadRelatedPartsTable(table, part_id, options={}) {
|
||||
*/
|
||||
function loadParametricPartTable(table, options={}) {
|
||||
|
||||
setupFilterList('parameters', $(table), '#filter-list-parameters');
|
||||
|
||||
var columns = [
|
||||
{
|
||||
field: 'name',
|
||||
|
Reference in New Issue
Block a user