Merge branch 'master' into pui-plugins

This commit is contained in:
Oliver Walters 2024-06-18 12:39:14 +00:00
commit 15ebbb6f04
9 changed files with 193 additions and 116 deletions

View File

@ -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',
]

View File

@ -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 (

View File

@ -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
"""

View File

@ -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

View File

@ -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)

View File

@ -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):

View File

@ -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.

View File

@ -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."""

View File

@ -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)'})