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 = [
|
||||
'bom_item__sub_part__name',
|
||||
'bom_item__sub_part__IPN',
|
||||
'bom_item__sub_part__description',
|
||||
'bom_item__reference',
|
||||
]
|
||||
|
||||
|
@ -7,6 +7,8 @@ from django.conf import settings
|
||||
|
||||
from jinja2 import Environment, select_autoescape
|
||||
|
||||
from common.settings import get_global_setting
|
||||
|
||||
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.
|
||||
"""
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
global _part_full_name_template
|
||||
global _part_full_name_template_string
|
||||
|
||||
template_string = InvenTreeSetting.get_setting(
|
||||
'PART_NAME_FORMAT', backup_value='', cache=True
|
||||
)
|
||||
template_string = get_global_setting('PART_NAME_FORMAT', cache=True)
|
||||
|
||||
# Skip if the template string has not changed
|
||||
if (
|
||||
|
@ -3,7 +3,6 @@
|
||||
Provides URL endpoints for:
|
||||
- Display / Create / Edit / Delete PartCategory
|
||||
- Display / Create / Edit / Delete Part
|
||||
- Create / Edit / Delete PartAttachment
|
||||
- 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:
|
||||
if data[field] is None:
|
||||
if data.get(field, None) is None:
|
||||
data[field] = ''
|
||||
|
||||
# Skip any "builtin" labels
|
||||
|
@ -1408,6 +1408,22 @@ class StockTrackingList(ListAPI):
|
||||
|
||||
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):
|
||||
"""List all stock tracking entries."""
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
@ -1421,84 +1437,36 @@ class StockTrackingList(ListAPI):
|
||||
|
||||
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:
|
||||
deltas = item['deltas']
|
||||
|
||||
if not deltas:
|
||||
deltas = {}
|
||||
for key in delta_models.keys():
|
||||
if key in deltas:
|
||||
related_model_lookups[key].add(deltas[key])
|
||||
|
||||
# Add part detail
|
||||
if 'part' in deltas:
|
||||
try:
|
||||
part = Part.objects.get(pk=deltas['part'])
|
||||
serializer = PartBriefSerializer(part)
|
||||
deltas['part_detail'] = serializer.data
|
||||
except Exception:
|
||||
pass
|
||||
for key in delta_models.keys():
|
||||
model, serializer = delta_models[key]
|
||||
|
||||
# Add location detail
|
||||
if 'location' in deltas:
|
||||
try:
|
||||
location = StockLocation.objects.get(pk=deltas['location'])
|
||||
serializer = StockSerializers.LocationSerializer(location)
|
||||
deltas['location_detail'] = serializer.data
|
||||
except Exception:
|
||||
pass
|
||||
# Fetch all related models in one go
|
||||
related_models = model.objects.filter(pk__in=related_model_lookups[key])
|
||||
|
||||
# Add stockitem detail
|
||||
if 'stockitem' in deltas:
|
||||
try:
|
||||
stockitem = StockItem.objects.get(pk=deltas['stockitem'])
|
||||
serializer = StockSerializers.StockItemSerializer(stockitem)
|
||||
deltas['stockitem_detail'] = serializer.data
|
||||
except Exception:
|
||||
pass
|
||||
# Construct a mapping of pk -> serialized data
|
||||
related_data = {obj.pk: serializer(obj).data for obj in related_models}
|
||||
|
||||
# Add customer detail
|
||||
if 'customer' in deltas:
|
||||
try:
|
||||
customer = Company.objects.get(pk=deltas['customer'])
|
||||
serializer = CompanySerializer(customer)
|
||||
deltas['customer_detail'] = serializer.data
|
||||
except Exception:
|
||||
pass
|
||||
# Now, update the data with the serialized data
|
||||
for item in data:
|
||||
deltas = item['deltas']
|
||||
|
||||
# Add PurchaseOrder detail
|
||||
if 'purchaseorder' in deltas:
|
||||
try:
|
||||
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 key in deltas:
|
||||
item['deltas'][f'{key}_detail'] = related_data.get(
|
||||
deltas[key], None
|
||||
)
|
||||
|
||||
if page is not None:
|
||||
return self.get_paginated_response(data)
|
||||
|
@ -2224,15 +2224,16 @@ def after_delete_stock_item(sender, instance: StockItem, **kwargs):
|
||||
"""Function to be executed after a StockItem object is deleted."""
|
||||
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
|
||||
InvenTree.tasks.offload_task(
|
||||
part_tasks.notify_low_stock_if_required, instance.part
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
@receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log')
|
||||
@ -2240,14 +2241,18 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
|
||||
"""Hook function to be executed after StockItem object is saved/updated."""
|
||||
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
|
||||
InvenTree.tasks.offload_task(
|
||||
part_tasks.notify_low_stock_if_required, instance.part
|
||||
)
|
||||
|
||||
if InvenTree.ready.canAppAccessDatabase(allow_test=True):
|
||||
instance.part.schedule_pricing_update(create=True)
|
||||
# Schedule an update on parent part pricing
|
||||
instance.part.schedule_pricing_update(create=True)
|
||||
|
||||
|
||||
class StockItemAttachment(InvenTree.models.InvenTreeAttachment):
|
||||
|
@ -391,6 +391,31 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
|
||||
"""
|
||||
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(
|
||||
queryset=part_models.Part.objects.all(),
|
||||
many=False,
|
||||
@ -547,31 +572,6 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
|
||||
|
||||
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):
|
||||
"""A DRF serializer for "serializing" a StockItem.
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
import io
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from enum import IntEnum
|
||||
|
||||
@ -22,6 +23,7 @@ from part.models import Part, PartTestTemplate
|
||||
from stock.models import (
|
||||
StockItem,
|
||||
StockItemTestResult,
|
||||
StockItemTracking,
|
||||
StockLocation,
|
||||
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):
|
||||
"""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')
|
||||
|
||||
|
||||
@task(pre=[wait], help={'address': 'Server address:port (default=0.0.0.0:8000)'})
|
||||
def gunicorn(c, address='0.0.0.0:8000'):
|
||||
@task(
|
||||
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.
|
||||
|
||||
Note: This server will not auto-reload in response to code changes.
|
||||
"""
|
||||
c.run(
|
||||
'gunicorn -c ./docker/gunicorn.conf.py InvenTree.wsgi -b {address} --chdir ./InvenTree'.format(
|
||||
address=address
|
||||
),
|
||||
pty=True,
|
||||
)
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
config_file = os.path.join(here, 'contrib', 'container', 'gunicorn.conf.py')
|
||||
chdir = os.path.join(here, 'src', 'backend', 'InvenTree')
|
||||
|
||||
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)'})
|
||||
|
Loading…
Reference in New Issue
Block a user