diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index f84bca1273..dd0e3bb6b6 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -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', ] diff --git a/src/backend/InvenTree/part/helpers.py b/src/backend/InvenTree/part/helpers.py index ab20c795d7..fcd24b0f2e 100644 --- a/src/backend/InvenTree/part/helpers.py +++ b/src/backend/InvenTree/part/helpers.py @@ -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 ( diff --git a/src/backend/InvenTree/part/urls.py b/src/backend/InvenTree/part/urls.py index 35eca76837..9b05133607 100644 --- a/src/backend/InvenTree/part/urls.py +++ b/src/backend/InvenTree/part/urls.py @@ -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 """ diff --git a/src/backend/InvenTree/report/migrations/0026_auto_20240422_1301.py b/src/backend/InvenTree/report/migrations/0026_auto_20240422_1301.py index 5f7bc8afd1..e1ce0fbd69 100644 --- a/src/backend/InvenTree/report/migrations/0026_auto_20240422_1301.py +++ b/src/backend/InvenTree/report/migrations/0026_auto_20240422_1301.py @@ -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 diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index e78fd277a0..4142083006 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -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) diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 5b6c6dab18..192b8fa62f 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -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): diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 3cfd845803..2e0c2d2531 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -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. diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 91dbccfbb0..a0cf28c3bf 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -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.""" diff --git a/tasks.py b/tasks.py index d585f103f8..3127465120 100644 --- a/tasks.py +++ b/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)'})