diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 76b918459c..80389bda95 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -4,8 +4,13 @@ import logging from django.apps import AppConfig from django.core.exceptions import AppRegistryNotReady +from django.conf import settings +from django.contrib.auth import get_user_model +from django.db import transaction +from django.db.utils import IntegrityError from InvenTree.ready import isInTestMode, canAppAccessDatabase +from .config import get_setting import InvenTree.tasks @@ -26,6 +31,9 @@ class InvenTreeConfig(AppConfig): if not isInTestMode(): self.update_exchange_rates() + if canAppAccessDatabase() or settings.TESTING_ENV: + self.add_user_on_startup() + def remove_obsolete_tasks(self): """ Delete any obsolete scheduled tasks in the database @@ -138,3 +146,54 @@ class InvenTreeConfig(AppConfig): update_exchange_rates() except Exception as e: logger.error(f"Error updating exchange rates: {e}") + + def add_user_on_startup(self): + """Add a user on startup""" + # stop if checks were already created + if hasattr(settings, 'USER_ADDED') and settings.USER_ADDED: + return + + # get values + add_user = get_setting( + 'INVENTREE_ADMIN_USER', + settings.CONFIG.get('admin_user', False) + ) + add_email = get_setting( + 'INVENTREE_ADMIN_EMAIL', + settings.CONFIG.get('admin_email', False) + ) + add_password = get_setting( + 'INVENTREE_ADMIN_PASSWORD', + settings.CONFIG.get('admin_password', False) + ) + + # check if all values are present + set_variables = 0 + for tested_var in [add_user, add_email, add_password]: + if tested_var: + set_variables += 1 + + # no variable set -> do not try anything + if set_variables == 0: + settings.USER_ADDED = True + return + + # not all needed variables set + if set_variables < 3: + logger.warn('Not all required settings for adding a user on startup are present:\nINVENTREE_SET_USER, INVENTREE_SET_EMAIL, INVENTREE_SET_PASSWORD') + settings.USER_ADDED = True + return + + # good to go -> create user + user = get_user_model() + try: + with transaction.atomic(): + new_user = user.objects.create_superuser(add_user, add_email, add_password) + logger.info(f'User {str(new_user)} was created!') + except IntegrityError as _e: + logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}') + if settings.TESTING_ENV: + raise _e + + # do not try again + settings.USER_ADDED = True diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 0fe3136871..99232519dc 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -133,7 +133,7 @@ class ReferenceIndexingMixin(models.Model): reference_int = models.BigIntegerField(default=0) -def extract_int(reference): +def extract_int(reference, clip=0x7fffffff): # Default value if we cannot convert to an integer ref_int = 0 @@ -146,6 +146,15 @@ def extract_int(reference): ref_int = int(ref) except: ref_int = 0 + + # Ensure that the returned values are within the range that can be stored in an IntegerField + # Note: This will result in large values being "clipped" + if clip is not None: + if ref_int > clip: + ref_int = clip + elif ref_int < -clip: + ref_int = -clip + return ref_int diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 121f1b6383..279225355d 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -37,6 +37,8 @@ def _is_true(x): # Determine if we are running in "test" mode e.g. "manage.py test" TESTING = 'test' in sys.argv +# Are enviroment variables manipulated by tests? Needs to be set by testing code +TESTING_ENV = False # New requirement for django 3.2+ DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 205231eb7b..f89a8b073d 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -1,9 +1,12 @@ import json +from test.support import EnvironmentVarGuard -from django.test import TestCase +from django.test import TestCase, override_settings import django.core.exceptions as django_exceptions from django.core.exceptions import ValidationError +from django.contrib.auth import get_user_model +from django.conf import settings from djmoney.money import Money from djmoney.contrib.exchange.models import Rate, convert_money @@ -407,3 +410,46 @@ class TestStatus(TestCase): def test_Importing(self): self.assertEqual(ready.isImportingData(), False) + + +class TestSettings(TestCase): + """ + Unit tests for settings + """ + + def setUp(self) -> None: + self.user_mdl = get_user_model() + self.env = EnvironmentVarGuard() + + def run_reload(self): + from plugin import registry + + with self.env: + settings.USER_ADDED = False + registry.reload_plugins() + + @override_settings(TESTING_ENV=True) + def test_set_user_to_few(self): + # add shortcut + user_count = self.user_mdl.objects.count + # enable testing mode + settings.TESTING_ENV = True + + # nothing set + self.run_reload() + self.assertEqual(user_count(), 0) + + # not enough set + self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username + self.run_reload() + self.assertEqual(user_count(), 0) + + # enough set + self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username + self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email + self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password + self.run_reload() + self.assertEqual(user_count(), 1) + + # make sure to clean up + settings.TESTING_ENV = False diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index c3d34a4dfd..5a6e213443 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,14 +12,18 @@ import common.models INVENTREE_SW_VERSION = "0.7.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 28 +INVENTREE_API_VERSION = 29 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about -v28 -> 2022-03-01 +v29 -> 2022-03-08 - Adds "scheduling" endpoint for predicted stock scheduling information +v28 -> 2022-03-04 + - Adds an API endpoint for auto allocation of stock items against a build order + - Ref: https://github.com/inventree/InvenTree/pull/2713 + v27 -> 2022-02-28 - Adds target_date field to individual line items for purchase orders and sales orders diff --git a/InvenTree/barcodes/api.py b/InvenTree/barcodes/api.py index 0d3656ce4c..047db357a4 100644 --- a/InvenTree/barcodes/api.py +++ b/InvenTree/barcodes/api.py @@ -12,6 +12,7 @@ from rest_framework.views import APIView from stock.models import StockItem from stock.serializers import StockItemSerializer +from barcodes.plugins.inventree_barcode import InvenTreeBarcodePlugin from barcodes.barcode import hash_barcode from plugin import registry @@ -57,6 +58,9 @@ class BarcodeScan(APIView): barcode_data = data.get('barcode') + # Ensure that the default barcode handler is installed + plugins.append(InvenTreeBarcodePlugin()) + # Look for a barcode plugin which knows how to deal with this barcode plugin = None diff --git a/InvenTree/barcodes/plugins/inventree_barcode.py b/InvenTree/barcodes/plugins/inventree_barcode.py index 842f9029aa..1b451f0286 100644 --- a/InvenTree/barcodes/plugins/inventree_barcode.py +++ b/InvenTree/barcodes/plugins/inventree_barcode.py @@ -52,7 +52,7 @@ class InvenTreeBarcodePlugin(BarcodePlugin): # If any of the following keys are in the JSON data, # let's go ahead and assume that the code is a valid InvenTree one... - for key in ['tool', 'version', 'InvenTree', 'stockitem', 'location', 'part']: + for key in ['tool', 'version', 'InvenTree', 'stockitem', 'stocklocation', 'part']: if key in self.data.keys(): return True diff --git a/InvenTree/barcodes/tests.py b/InvenTree/barcodes/tests.py index a9795c3928..c9a063e8f0 100644 --- a/InvenTree/barcodes/tests.py +++ b/InvenTree/barcodes/tests.py @@ -56,6 +56,66 @@ class BarcodeAPITest(APITestCase): self.assertIn('plugin', data) self.assertIsNone(data['plugin']) + def test_find_part(self): + """ + Test that we can lookup a part based on ID + """ + + response = self.client.post( + self.scan_url, + { + 'barcode': { + 'part': 1, + }, + }, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('part', response.data) + self.assertIn('barcode_data', response.data) + self.assertEqual(response.data['part']['pk'], 1) + + def test_find_stock_item(self): + """ + Test that we can lookup a stock item based on ID + """ + + response = self.client.post( + self.scan_url, + { + 'barcode': { + 'stockitem': 1, + } + }, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('stockitem', response.data) + self.assertIn('barcode_data', response.data) + self.assertEqual(response.data['stockitem']['pk'], 1) + + def test_find_location(self): + """ + Test that we can lookup a stock location based on ID + """ + + response = self.client.post( + self.scan_url, + { + 'barcode': { + 'stocklocation': 1, + }, + }, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('stocklocation', response.data) + self.assertIn('barcode_data', response.data) + self.assertEqual(response.data['stocklocation']['pk'], 1) + def test_integer_barcode(self): response = self.postBarcode(self.scan_url, '123456789') diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 310d4d7f09..114268fa2b 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -322,6 +322,37 @@ class BuildFinish(generics.CreateAPIView): return ctx +class BuildAutoAllocate(generics.CreateAPIView): + """ + API endpoint for 'automatically' allocating stock against a build order. + + - Only looks at 'untracked' parts + - If stock exists in a single location, easy! + - If user decides that stock items are "fungible", allocate against multiple stock items + - If the user wants to, allocate substite parts if the primary parts are not available. + """ + + queryset = Build.objects.none() + + serializer_class = build.serializers.BuildAutoAllocationSerializer + + def get_serializer_context(self): + """ + Provide the Build object to the serializer context + """ + + context = super().get_serializer_context() + + try: + context['build'] = Build.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + context['request'] = self.request + + return context + + class BuildAllocate(generics.CreateAPIView): """ API endpoint to allocate stock items to a build order @@ -477,6 +508,7 @@ build_api_urls = [ # Build Detail url(r'^(?P\d+)/', include([ url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), + url(r'^auto-allocate/', BuildAutoAllocate.as_view(), name='api-build-auto-allocate'), url(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'), url(r'^create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'), url(r'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'), diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 01c2c781e9..e4a5013b7b 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -25,6 +25,8 @@ from markdownx.models import MarkdownxField from mptt.models import MPTTModel, TreeForeignKey from mptt.exceptions import InvalidMove +from rest_framework import serializers + from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin @@ -823,6 +825,106 @@ class Build(MPTTModel, ReferenceIndexingMixin): self.save() + @transaction.atomic + def auto_allocate_stock(self, **kwargs): + """ + Automatically allocate stock items against this build order, + following a number of 'guidelines': + + - Only "untracked" BOM items are considered (tracked BOM items must be manually allocated) + - If a particular BOM item is already fully allocated, it is skipped + - Extract all available stock items for the BOM part + - If variant stock is allowed, extract stock for those too + - If substitute parts are available, extract stock for those also + - If a single stock item is found, we can allocate that and move on! + - If multiple stock items are found, we *may* be able to allocate: + - If the calling function has specified that items are interchangeable + """ + + location = kwargs.get('location', None) + interchangeable = kwargs.get('interchangeable', False) + substitutes = kwargs.get('substitutes', True) + + # Get a list of all 'untracked' BOM items + for bom_item in self.untracked_bom_items: + + variant_parts = bom_item.sub_part.get_descendants(include_self=False) + + unallocated_quantity = self.unallocated_quantity(bom_item) + + if unallocated_quantity <= 0: + # This BomItem is fully allocated, we can continue + continue + + # Check which parts we can "use" (may include variants and substitutes) + available_parts = bom_item.get_valid_parts_for_allocation( + allow_variants=True, + allow_substitutes=substitutes, + ) + + # Look for available stock items + available_stock = StockModels.StockItem.objects.filter(StockModels.StockItem.IN_STOCK_FILTER) + + # Filter by list of available parts + available_stock = available_stock.filter( + part__in=[p for p in available_parts], + ) + + if location: + # Filter only stock items located "below" the specified location + sublocations = location.get_descendants(include_self=True) + available_stock = available_stock.filter(location__in=[loc for loc in sublocations]) + + """ + Next, we sort the available stock items with the following priority: + 1. Direct part matches (+1) + 2. Variant part matches (+2) + 3. Substitute part matches (+3) + + This ensures that allocation priority is first given to "direct" parts + """ + def stock_sort(item): + if item.part == bom_item.sub_part: + return 1 + elif item.part in variant_parts: + return 2 + else: + return 3 + + available_stock = sorted(available_stock, key=stock_sort) + + if len(available_stock) == 0: + # No stock items are available + continue + elif len(available_stock) == 1 or interchangeable: + # Either there is only a single stock item available, + # or all items are "interchangeable" and we don't care where we take stock from + + for stock_item in available_stock: + # How much of the stock item is "available" for allocation? + quantity = min(unallocated_quantity, stock_item.unallocated_quantity()) + + if quantity > 0: + + try: + BuildItem.objects.create( + build=self, + bom_item=bom_item, + stock_item=stock_item, + quantity=quantity, + ) + + # Subtract the required quantity + unallocated_quantity -= quantity + + except (ValidationError, serializers.ValidationError) as exc: + # Catch model errors and re-throw as DRF errors + raise ValidationError(detail=serializers.as_serializer_error(exc)) + + if unallocated_quantity <= 0: + # We have now fully-allocated this BomItem - no need to continue! + break + def required_quantity(self, bom_item, output=None): """ Get the quantity of a part required to complete the particular build output. diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 0a8964ee82..ba43a079ff 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -709,6 +709,52 @@ class BuildAllocationSerializer(serializers.Serializer): raise ValidationError(detail=serializers.as_serializer_error(exc)) +class BuildAutoAllocationSerializer(serializers.Serializer): + """ + DRF serializer for auto allocating stock items against a build order + """ + + class Meta: + fields = [ + 'location', + 'interchangeable', + 'substitutes', + ] + + location = serializers.PrimaryKeyRelatedField( + queryset=StockLocation.objects.all(), + many=False, + allow_null=True, + required=False, + label=_('Source Location'), + help_text=_('Stock location where parts are to be sourced (leave blank to take from any location)'), + ) + + interchangeable = serializers.BooleanField( + default=False, + label=_('Interchangeable Stock'), + help_text=_('Stock items in multiple locations can be used interchangeably'), + ) + + substitutes = serializers.BooleanField( + default=True, + label=_('Substitute Stock'), + help_text=_('Allow allocation of substitute parts'), + ) + + def save(self): + + data = self.validated_data + + build = self.context['build'] + + build.auto_allocate_stock( + location=data.get('location', None), + interchangeable=data['interchangeable'], + substitutes=data['substitutes'], + ) + + class BuildItemSerializer(InvenTreeModelSerializer): """ Serializes a BuildItem object """ diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 1e31857ba5..ca909f82f7 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -177,7 +177,10 @@ - +