mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' into pui-plugins
This commit is contained in:
commit
15ebbb6f04
@ -380,6 +380,8 @@ class BuildLineList(BuildLineEndpoint, ListCreateAPI):
|
|||||||
|
|
||||||
search_fields = [
|
search_fields = [
|
||||||
'bom_item__sub_part__name',
|
'bom_item__sub_part__name',
|
||||||
|
'bom_item__sub_part__IPN',
|
||||||
|
'bom_item__sub_part__description',
|
||||||
'bom_item__reference',
|
'bom_item__reference',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -7,6 +7,8 @@ from django.conf import settings
|
|||||||
|
|
||||||
from jinja2 import Environment, select_autoescape
|
from jinja2 import Environment, select_autoescape
|
||||||
|
|
||||||
|
from common.settings import get_global_setting
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
@ -20,14 +22,10 @@ def compile_full_name_template(*args, **kwargs):
|
|||||||
|
|
||||||
This function is called whenever the 'PART_NAME_FORMAT' setting is changed.
|
This function is called whenever the 'PART_NAME_FORMAT' setting is changed.
|
||||||
"""
|
"""
|
||||||
from common.models import InvenTreeSetting
|
|
||||||
|
|
||||||
global _part_full_name_template
|
global _part_full_name_template
|
||||||
global _part_full_name_template_string
|
global _part_full_name_template_string
|
||||||
|
|
||||||
template_string = InvenTreeSetting.get_setting(
|
template_string = get_global_setting('PART_NAME_FORMAT', cache=True)
|
||||||
'PART_NAME_FORMAT', backup_value='', cache=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Skip if the template string has not changed
|
# Skip if the template string has not changed
|
||||||
if (
|
if (
|
||||||
|
@ -3,7 +3,6 @@
|
|||||||
Provides URL endpoints for:
|
Provides URL endpoints for:
|
||||||
- Display / Create / Edit / Delete PartCategory
|
- Display / Create / Edit / Delete PartCategory
|
||||||
- Display / Create / Edit / Delete Part
|
- Display / Create / Edit / Delete Part
|
||||||
- Create / Edit / Delete PartAttachment
|
|
||||||
- Display / Create / Edit / Delete SupplierPart
|
- Display / Create / Edit / Delete SupplierPart
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -59,7 +59,7 @@ def convert_legacy_labels(table_name, model_name, template_model):
|
|||||||
}
|
}
|
||||||
|
|
||||||
for field in non_null_fields:
|
for field in non_null_fields:
|
||||||
if data[field] is None:
|
if data.get(field, None) is None:
|
||||||
data[field] = ''
|
data[field] = ''
|
||||||
|
|
||||||
# Skip any "builtin" labels
|
# Skip any "builtin" labels
|
||||||
|
@ -1408,6 +1408,22 @@ class StockTrackingList(ListAPI):
|
|||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return self.serializer_class(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_delta_model_map(self) -> dict:
|
||||||
|
"""Return a mapping of delta models to their respective models and serializers.
|
||||||
|
|
||||||
|
This is used to generate additional context information for the historical data,
|
||||||
|
with some attempt at caching so that we can reduce the number of database hits.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'part': (Part, PartBriefSerializer),
|
||||||
|
'location': (StockLocation, StockSerializers.LocationSerializer),
|
||||||
|
'customer': (Company, CompanySerializer),
|
||||||
|
'purchaseorder': (PurchaseOrder, PurchaseOrderSerializer),
|
||||||
|
'salesorder': (SalesOrder, SalesOrderSerializer),
|
||||||
|
'returnorder': (ReturnOrder, ReturnOrderSerializer),
|
||||||
|
'buildorder': (Build, BuildSerializer),
|
||||||
|
}
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
"""List all stock tracking entries."""
|
"""List all stock tracking entries."""
|
||||||
queryset = self.filter_queryset(self.get_queryset())
|
queryset = self.filter_queryset(self.get_queryset())
|
||||||
@ -1421,84 +1437,36 @@ class StockTrackingList(ListAPI):
|
|||||||
|
|
||||||
data = serializer.data
|
data = serializer.data
|
||||||
|
|
||||||
# Attempt to add extra context information to the historical data
|
delta_models = self.get_delta_model_map()
|
||||||
|
|
||||||
|
# Construct a set of related models we need to lookup for later
|
||||||
|
related_model_lookups = {key: set() for key in delta_models.keys()}
|
||||||
|
|
||||||
|
# Run a first pass through the data to determine which related models we need to lookup
|
||||||
for item in data:
|
for item in data:
|
||||||
deltas = item['deltas']
|
deltas = item['deltas']
|
||||||
|
|
||||||
if not deltas:
|
for key in delta_models.keys():
|
||||||
deltas = {}
|
if key in deltas:
|
||||||
|
related_model_lookups[key].add(deltas[key])
|
||||||
|
|
||||||
# Add part detail
|
for key in delta_models.keys():
|
||||||
if 'part' in deltas:
|
model, serializer = delta_models[key]
|
||||||
try:
|
|
||||||
part = Part.objects.get(pk=deltas['part'])
|
|
||||||
serializer = PartBriefSerializer(part)
|
|
||||||
deltas['part_detail'] = serializer.data
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Add location detail
|
# Fetch all related models in one go
|
||||||
if 'location' in deltas:
|
related_models = model.objects.filter(pk__in=related_model_lookups[key])
|
||||||
try:
|
|
||||||
location = StockLocation.objects.get(pk=deltas['location'])
|
|
||||||
serializer = StockSerializers.LocationSerializer(location)
|
|
||||||
deltas['location_detail'] = serializer.data
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Add stockitem detail
|
# Construct a mapping of pk -> serialized data
|
||||||
if 'stockitem' in deltas:
|
related_data = {obj.pk: serializer(obj).data for obj in related_models}
|
||||||
try:
|
|
||||||
stockitem = StockItem.objects.get(pk=deltas['stockitem'])
|
|
||||||
serializer = StockSerializers.StockItemSerializer(stockitem)
|
|
||||||
deltas['stockitem_detail'] = serializer.data
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Add customer detail
|
# Now, update the data with the serialized data
|
||||||
if 'customer' in deltas:
|
for item in data:
|
||||||
try:
|
deltas = item['deltas']
|
||||||
customer = Company.objects.get(pk=deltas['customer'])
|
|
||||||
serializer = CompanySerializer(customer)
|
|
||||||
deltas['customer_detail'] = serializer.data
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Add PurchaseOrder detail
|
if key in deltas:
|
||||||
if 'purchaseorder' in deltas:
|
item['deltas'][f'{key}_detail'] = related_data.get(
|
||||||
try:
|
deltas[key], None
|
||||||
order = PurchaseOrder.objects.get(pk=deltas['purchaseorder'])
|
)
|
||||||
serializer = PurchaseOrderSerializer(order)
|
|
||||||
deltas['purchaseorder_detail'] = serializer.data
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Add SalesOrder detail
|
|
||||||
if 'salesorder' in deltas:
|
|
||||||
try:
|
|
||||||
order = SalesOrder.objects.get(pk=deltas['salesorder'])
|
|
||||||
serializer = SalesOrderSerializer(order)
|
|
||||||
deltas['salesorder_detail'] = serializer.data
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Add ReturnOrder detail
|
|
||||||
if 'returnorder' in deltas:
|
|
||||||
try:
|
|
||||||
order = ReturnOrder.objects.get(pk=deltas['returnorder'])
|
|
||||||
serializer = ReturnOrderSerializer(order)
|
|
||||||
deltas['returnorder_detail'] = serializer.data
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Add BuildOrder detail
|
|
||||||
if 'buildorder' in deltas:
|
|
||||||
try:
|
|
||||||
order = Build.objects.get(pk=deltas['buildorder'])
|
|
||||||
serializer = BuildSerializer(order)
|
|
||||||
deltas['buildorder_detail'] = serializer.data
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if page is not None:
|
if page is not None:
|
||||||
return self.get_paginated_response(data)
|
return self.get_paginated_response(data)
|
||||||
|
@ -2224,14 +2224,15 @@ def after_delete_stock_item(sender, instance: StockItem, **kwargs):
|
|||||||
"""Function to be executed after a StockItem object is deleted."""
|
"""Function to be executed after a StockItem object is deleted."""
|
||||||
from part import tasks as part_tasks
|
from part import tasks as part_tasks
|
||||||
|
|
||||||
if not InvenTree.ready.isImportingData():
|
if not InvenTree.ready.isImportingData() and InvenTree.ready.canAppAccessDatabase(
|
||||||
|
allow_test=True
|
||||||
|
):
|
||||||
# Run this check in the background
|
# Run this check in the background
|
||||||
InvenTree.tasks.offload_task(
|
InvenTree.tasks.offload_task(
|
||||||
part_tasks.notify_low_stock_if_required, instance.part
|
part_tasks.notify_low_stock_if_required, instance.part
|
||||||
)
|
)
|
||||||
|
|
||||||
# Schedule an update on parent part pricing
|
# Schedule an update on parent part pricing
|
||||||
if InvenTree.ready.canAppAccessDatabase(allow_test=True):
|
|
||||||
instance.part.schedule_pricing_update(create=False)
|
instance.part.schedule_pricing_update(create=False)
|
||||||
|
|
||||||
|
|
||||||
@ -2240,13 +2241,17 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
|
|||||||
"""Hook function to be executed after StockItem object is saved/updated."""
|
"""Hook function to be executed after StockItem object is saved/updated."""
|
||||||
from part import tasks as part_tasks
|
from part import tasks as part_tasks
|
||||||
|
|
||||||
if created and not InvenTree.ready.isImportingData():
|
if (
|
||||||
|
created
|
||||||
|
and not InvenTree.ready.isImportingData()
|
||||||
|
and InvenTree.ready.canAppAccessDatabase(allow_test=True)
|
||||||
|
):
|
||||||
# Run this check in the background
|
# Run this check in the background
|
||||||
InvenTree.tasks.offload_task(
|
InvenTree.tasks.offload_task(
|
||||||
part_tasks.notify_low_stock_if_required, instance.part
|
part_tasks.notify_low_stock_if_required, instance.part
|
||||||
)
|
)
|
||||||
|
|
||||||
if InvenTree.ready.canAppAccessDatabase(allow_test=True):
|
# Schedule an update on parent part pricing
|
||||||
instance.part.schedule_pricing_update(create=True)
|
instance.part.schedule_pricing_update(create=True)
|
||||||
|
|
||||||
|
|
||||||
|
@ -391,6 +391,31 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
|
|||||||
"""
|
"""
|
||||||
extra_kwargs = {'use_pack_size': {'write_only': True}}
|
extra_kwargs = {'use_pack_size': {'write_only': True}}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Add detail fields."""
|
||||||
|
part_detail = kwargs.pop('part_detail', False)
|
||||||
|
location_detail = kwargs.pop('location_detail', False)
|
||||||
|
supplier_part_detail = kwargs.pop('supplier_part_detail', False)
|
||||||
|
tests = kwargs.pop('tests', False)
|
||||||
|
path_detail = kwargs.pop('path_detail', False)
|
||||||
|
|
||||||
|
super(StockItemSerializer, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if not part_detail:
|
||||||
|
self.fields.pop('part_detail')
|
||||||
|
|
||||||
|
if not location_detail:
|
||||||
|
self.fields.pop('location_detail')
|
||||||
|
|
||||||
|
if not supplier_part_detail:
|
||||||
|
self.fields.pop('supplier_part_detail')
|
||||||
|
|
||||||
|
if not tests:
|
||||||
|
self.fields.pop('tests')
|
||||||
|
|
||||||
|
if not path_detail:
|
||||||
|
self.fields.pop('location_path')
|
||||||
|
|
||||||
part = serializers.PrimaryKeyRelatedField(
|
part = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=part_models.Part.objects.all(),
|
queryset=part_models.Part.objects.all(),
|
||||||
many=False,
|
many=False,
|
||||||
@ -547,31 +572,6 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
|
|||||||
|
|
||||||
tags = TagListSerializerField(required=False)
|
tags = TagListSerializerField(required=False)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
"""Add detail fields."""
|
|
||||||
part_detail = kwargs.pop('part_detail', False)
|
|
||||||
location_detail = kwargs.pop('location_detail', False)
|
|
||||||
supplier_part_detail = kwargs.pop('supplier_part_detail', False)
|
|
||||||
tests = kwargs.pop('tests', False)
|
|
||||||
path_detail = kwargs.pop('path_detail', False)
|
|
||||||
|
|
||||||
super(StockItemSerializer, self).__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
if not part_detail:
|
|
||||||
self.fields.pop('part_detail')
|
|
||||||
|
|
||||||
if not location_detail:
|
|
||||||
self.fields.pop('location_detail')
|
|
||||||
|
|
||||||
if not supplier_part_detail:
|
|
||||||
self.fields.pop('supplier_part_detail')
|
|
||||||
|
|
||||||
if not tests:
|
|
||||||
self.fields.pop('tests')
|
|
||||||
|
|
||||||
if not path_detail:
|
|
||||||
self.fields.pop('location_path')
|
|
||||||
|
|
||||||
|
|
||||||
class SerializeStockItemSerializer(serializers.Serializer):
|
class SerializeStockItemSerializer(serializers.Serializer):
|
||||||
"""A DRF serializer for "serializing" a StockItem.
|
"""A DRF serializer for "serializing" a StockItem.
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
|
|
||||||
@ -22,6 +23,7 @@ from part.models import Part, PartTestTemplate
|
|||||||
from stock.models import (
|
from stock.models import (
|
||||||
StockItem,
|
StockItem,
|
||||||
StockItemTestResult,
|
StockItemTestResult,
|
||||||
|
StockItemTracking,
|
||||||
StockLocation,
|
StockLocation,
|
||||||
StockLocationType,
|
StockLocationType,
|
||||||
)
|
)
|
||||||
@ -1770,6 +1772,96 @@ class StockTestResultTest(StockAPITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StockTrackingTest(StockAPITestCase):
|
||||||
|
"""Tests for the StockTracking API endpoints."""
|
||||||
|
|
||||||
|
fixtures = [
|
||||||
|
*StockAPITestCase.fixtures,
|
||||||
|
'build',
|
||||||
|
'order',
|
||||||
|
'return_order',
|
||||||
|
'sales_order',
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
"""Initialize some test data for the StockTracking tests."""
|
||||||
|
super().setUpTestData()
|
||||||
|
|
||||||
|
import build.models
|
||||||
|
import company.models
|
||||||
|
import order.models
|
||||||
|
import stock.models
|
||||||
|
import stock.status_codes
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
|
||||||
|
N_BO = build.models.Build.objects.count()
|
||||||
|
N_PO = order.models.PurchaseOrder.objects.count()
|
||||||
|
N_RO = order.models.ReturnOrder.objects.count()
|
||||||
|
N_SO = order.models.SalesOrder.objects.count()
|
||||||
|
|
||||||
|
N_COMPANY = company.models.Company.objects.count()
|
||||||
|
N_LOCATION = stock.models.StockLocation.objects.count()
|
||||||
|
|
||||||
|
# Generate a large quantity of tracking items
|
||||||
|
# Note that the pk values are not guaranteed to exist in the database
|
||||||
|
for item in StockItem.objects.all():
|
||||||
|
for i in range(50):
|
||||||
|
entries.append(
|
||||||
|
StockItemTracking(
|
||||||
|
item=item,
|
||||||
|
notes='This is a test entry',
|
||||||
|
tracking_type=stock.status_codes.StockHistoryCode.LEGACY.value,
|
||||||
|
deltas={
|
||||||
|
'quantity': 50 - i,
|
||||||
|
'buildorder': random.randint(0, N_BO + 1),
|
||||||
|
'purchaseorder': random.randint(0, N_PO + 1),
|
||||||
|
'returnorder': random.randint(0, N_RO + 1),
|
||||||
|
'salesorder': random.randint(0, N_SO + 1),
|
||||||
|
'customer': random.randint(0, N_COMPANY + 1),
|
||||||
|
'location': random.randint(0, N_LOCATION + 1),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
StockItemTracking.objects.bulk_create(entries)
|
||||||
|
|
||||||
|
def get_url(self):
|
||||||
|
"""Helper function to get stock tracking api url."""
|
||||||
|
return reverse('api-stock-tracking-list')
|
||||||
|
|
||||||
|
def test_count(self):
|
||||||
|
"""Test list endpoint with limit = 1."""
|
||||||
|
url = self.get_url()
|
||||||
|
|
||||||
|
N = StockItemTracking.objects.count()
|
||||||
|
|
||||||
|
# Test counting
|
||||||
|
response = self.get(url, {'limit': 1})
|
||||||
|
self.assertEqual(response.data['count'], N)
|
||||||
|
|
||||||
|
def test_list(self):
|
||||||
|
"""Test list endpoint."""
|
||||||
|
url = self.get_url()
|
||||||
|
|
||||||
|
N = StockItemTracking.objects.count()
|
||||||
|
self.assertGreater(N, 1000)
|
||||||
|
|
||||||
|
response = self.client.get(url, max_query_count=25)
|
||||||
|
self.assertEqual(len(response.data), N)
|
||||||
|
|
||||||
|
# Check expected delta values
|
||||||
|
keys = ['quantity', 'returnorder', 'buildorder', 'customer']
|
||||||
|
|
||||||
|
for item in response.data:
|
||||||
|
deltas = item['deltas']
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
self.assertIn(key, deltas)
|
||||||
|
self.assertIsNotNone(deltas.get(key, None))
|
||||||
|
|
||||||
|
|
||||||
class StockAssignTest(StockAPITestCase):
|
class StockAssignTest(StockAPITestCase):
|
||||||
"""Unit tests for the stock assignment API endpoint, where stock items are manually assigned to a customer."""
|
"""Unit tests for the stock assignment API endpoint, where stock items are manually assigned to a customer."""
|
||||||
|
|
||||||
|
29
tasks.py
29
tasks.py
@ -777,18 +777,31 @@ def wait(c):
|
|||||||
return manage(c, 'wait_for_db')
|
return manage(c, 'wait_for_db')
|
||||||
|
|
||||||
|
|
||||||
@task(pre=[wait], help={'address': 'Server address:port (default=0.0.0.0:8000)'})
|
@task(
|
||||||
def gunicorn(c, address='0.0.0.0:8000'):
|
pre=[wait],
|
||||||
|
help={
|
||||||
|
'address': 'Server address:port (default=0.0.0.0:8000)',
|
||||||
|
'workers': 'Specify number of worker threads (override config file)',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def gunicorn(c, address='0.0.0.0:8000', workers=None):
|
||||||
"""Launch a gunicorn webserver.
|
"""Launch a gunicorn webserver.
|
||||||
|
|
||||||
Note: This server will not auto-reload in response to code changes.
|
Note: This server will not auto-reload in response to code changes.
|
||||||
"""
|
"""
|
||||||
c.run(
|
here = os.path.dirname(os.path.abspath(__file__))
|
||||||
'gunicorn -c ./docker/gunicorn.conf.py InvenTree.wsgi -b {address} --chdir ./InvenTree'.format(
|
config_file = os.path.join(here, 'contrib', 'container', 'gunicorn.conf.py')
|
||||||
address=address
|
chdir = os.path.join(here, 'src', 'backend', 'InvenTree')
|
||||||
),
|
|
||||||
pty=True,
|
cmd = f'gunicorn -c {config_file} InvenTree.wsgi -b {address} --chdir {chdir}'
|
||||||
)
|
|
||||||
|
if workers:
|
||||||
|
cmd += f' --workers={workers}'
|
||||||
|
|
||||||
|
print('Starting Gunicorn Server:')
|
||||||
|
print(cmd)
|
||||||
|
|
||||||
|
c.run(cmd, pty=True)
|
||||||
|
|
||||||
|
|
||||||
@task(pre=[wait], help={'address': 'Server address:port (default=127.0.0.1:8000)'})
|
@task(pre=[wait], help={'address': 'Server address:port (default=127.0.0.1:8000)'})
|
||||||
|
Loading…
Reference in New Issue
Block a user