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 = [ 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',
] ]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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