diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 9170a83344..d903b737e4 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -29,6 +29,7 @@ jobs: - name: Install Dependencies run: | sudo apt-get update + sudo apt-get install gettext pip3 install invoke invoke install invoke static @@ -43,6 +44,8 @@ jobs: rm test_db.sqlite invoke migrate invoke import-records -f data.json + - name: Test Translations + run: invoke translate - name: Check Migration Files run: python3 ci/check_migration_files.py - name: Upload Coverage Report diff --git a/.github/workflows/docker_build.yaml b/.github/workflows/docker_build.yaml index e307c18452..df747bc56e 100644 --- a/.github/workflows/docker_build.yaml +++ b/.github/workflows/docker_build.yaml @@ -1,8 +1,11 @@ -# Test that the docker file builds correctly +# Build and push latest docker image on push to master branch -name: Docker +name: Docker Build -on: ["push", "pull_request"] +on: + push: + branches: + - 'master' jobs: @@ -10,7 +13,19 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Build Docker Image - run: cd docker && docker build . --tag inventree:$(date +%s) - \ No newline at end of file + - name: Checkout Code + uses: actions/checkout@v2 + - name: Login to Dockerhub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Build and Push + uses: docker/build-push-action@v2 + with: + context: ./docker + push: true + repository: inventree/inventree + tags: inventree/inventree:latest + - name: Image Digest + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 1e45c80425..1c7180fd4b 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -52,6 +52,9 @@ def get_setting(environment_var, backup_val, default_value=None): # Determine if we are running in "test" mode e.g. "manage.py test" TESTING = 'test' in sys.argv +# New requirement for django 3.2+ +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' + # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/InvenTree/InvenTree/status.py b/InvenTree/InvenTree/status.py index e9846e445a..5531d4c270 100644 --- a/InvenTree/InvenTree/status.py +++ b/InvenTree/InvenTree/status.py @@ -56,17 +56,26 @@ def is_email_configured(): configured = True if not settings.EMAIL_HOST: - logger.warning("EMAIL_HOST is not configured") configured = False + # Display warning unless in test mode + if not settings.TESTING: + logger.warning("EMAIL_HOST is not configured") + if not settings.EMAIL_HOST_USER: - logger.warning("EMAIL_HOST_USER is not configured") configured = False + + # Display warning unless in test mode + if not settings.TESTING: + logger.warning("EMAIL_HOST_USER is not configured") if not settings.EMAIL_HOST_PASSWORD: - logger.warning("EMAIL_HOST_PASSWORD is not configured") configured = False + # Display warning unless in test mode + if not settings.TESTING: + logger.warning("EMAIL_HOST_PASSWORD is not configured") + return configured diff --git a/InvenTree/InvenTree/status_codes.py b/InvenTree/InvenTree/status_codes.py index 5eb97504c6..6294eba06e 100644 --- a/InvenTree/InvenTree/status_codes.py +++ b/InvenTree/InvenTree/status_codes.py @@ -62,6 +62,14 @@ class StatusCode: def items(cls): return cls.options.items() + @classmethod + def keys(cls): + return cls.options.keys() + + @classmethod + def labels(cls): + return cls.options.values() + @classmethod def label(cls, value): """ Return the status code label associated with the provided value """ diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index e6331f2b6a..10cc7e2024 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -11,7 +11,7 @@ from rest_framework import generics from django.conf.urls import url, include -from InvenTree.helpers import str2bool +from InvenTree.helpers import str2bool, isNull from InvenTree.status_codes import BuildStatus from .models import Build, BuildItem @@ -194,7 +194,11 @@ class BuildItemList(generics.ListCreateAPIView): output = params.get('output', None) if output: - queryset = queryset.filter(install_into=output) + + if isNull(output): + queryset = queryset.filter(install_into=None) + else: + queryset = queryset.filter(install_into=output) return queryset diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index 0726779b87..e60df22c21 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -3,6 +3,7 @@ Django Forms for interacting with Build objects """ # -*- coding: utf-8 -*- + from __future__ import unicode_literals from django.utils.translation import ugettext_lazy as _ @@ -12,6 +13,8 @@ from InvenTree.forms import HelperForm from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import DatePickerFormField +from InvenTree.status_codes import StockStatus + from .models import Build, BuildItem, BuildOrderAttachment from stock.models import StockLocation, StockItem @@ -165,16 +168,10 @@ class AutoAllocateForm(HelperForm): confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm stock allocation')) - # Keep track of which build output we are interested in - output = forms.ModelChoiceField( - queryset=StockItem.objects.all(), - ) - class Meta: model = Build fields = [ 'confirm', - 'output', ] @@ -214,6 +211,13 @@ class CompleteBuildOutputForm(HelperForm): help_text=_('Location of completed parts'), ) + stock_status = forms.ChoiceField( + label=_('Status'), + help_text=_('Build output stock status'), + initial=StockStatus.OK, + choices=StockStatus.items(), + ) + confirm_incomplete = forms.BooleanField( required=False, label=_('Confirm incomplete'), @@ -232,10 +236,15 @@ class CompleteBuildOutputForm(HelperForm): fields = [ 'location', 'output', + 'stock_status', 'confirm', 'confirm_incomplete', ] + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + class CancelBuildForm(HelperForm): """ Form for cancelling a build """ diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 4ee8de0d73..16c0e5bb7f 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -22,7 +22,7 @@ from markdownx.models import MarkdownxField from mptt.models import MPTTModel, TreeForeignKey -from InvenTree.status_codes import BuildStatus +from InvenTree.status_codes import BuildStatus, StockStatus from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode from InvenTree.validators import validate_build_order_reference from InvenTree.models import InvenTreeAttachment @@ -314,6 +314,42 @@ class Build(MPTTModel): 'sub_part' ) + @property + def tracked_bom_items(self): + """ + Returns the "trackable" BOM items for this BuildOrder + """ + + items = self.bom_items + items = items.filter(sub_part__trackable=True) + + return items + + def has_tracked_bom_items(self): + """ + Returns True if this BuildOrder has trackable BomItems + """ + + return self.tracked_bom_items.count() > 0 + + @property + def untracked_bom_items(self): + """ + Returns the "non trackable" BOM items for this BuildOrder + """ + + items = self.bom_items + items = items.filter(sub_part__trackable=False) + + return items + + def has_untracked_bom_items(self): + """ + Returns True if this BuildOrder has non trackable BomItems + """ + + return self.untracked_bom_items.count() > 0 + @property def remaining(self): """ @@ -449,6 +485,9 @@ class Build(MPTTModel): if self.completed < self.quantity: return False + if not self.areUntrackedPartsFullyAllocated(): + return False + # No issues! return True @@ -458,7 +497,7 @@ class Build(MPTTModel): Mark this build as complete """ - if not self.can_complete: + if self.incomplete_count > 0: return self.completion_date = datetime.now().date() @@ -466,6 +505,9 @@ class Build(MPTTModel): self.status = BuildStatus.COMPLETE self.save() + # Remove untracked allocated stock + self.subtractUntrackedStock(user) + # Ensure that there are no longer any BuildItem objects # which point to thie Build Order self.allocated_stock.all().delete() @@ -489,7 +531,7 @@ class Build(MPTTModel): self.status = BuildStatus.CANCELLED self.save() - def getAutoAllocations(self, output): + def getAutoAllocations(self): """ Return a list of StockItem objects which will be allocated using the 'AutoAllocate' function. @@ -521,15 +563,19 @@ class Build(MPTTModel): part = bom_item.sub_part + # If the part is "trackable" it cannot be auto-allocated + if part.trackable: + continue + # Skip any parts which are already fully allocated - if self.isPartFullyAllocated(part, output): + if self.isPartFullyAllocated(part, None): continue # How many parts are required to complete the output? - required = self.unallocatedQuantity(part, output) + required = self.unallocatedQuantity(part, None) # Grab a list of stock items which are available - stock_items = self.availableStockItems(part, output) + stock_items = self.availableStockItems(part, None) # Ensure that the available stock items are in the correct location if self.take_from is not None: @@ -544,7 +590,6 @@ class Build(MPTTModel): build_items = BuildItem.objects.filter( build=self, stock_item=stock_item, - install_into=output ) if len(build_items) > 0: @@ -567,24 +612,45 @@ class Build(MPTTModel): return allocations @transaction.atomic - def unallocateStock(self, output=None, part=None): + def unallocateOutput(self, output, part=None): """ - Deletes all stock allocations for this build. - - Args: - output: Specify which build output to delete allocations (optional) - + Unallocate all stock which are allocated against the provided "output" (StockItem) """ - allocations = BuildItem.objects.filter(build=self.pk) - - if output: - allocations = allocations.filter(install_into=output.pk) + allocations = BuildItem.objects.filter( + build=self, + install_into=output + ) if part: allocations = allocations.filter(stock_item__part=part) - # Remove all the allocations + allocations.delete() + + @transaction.atomic + def unallocateUntracked(self, part=None): + """ + Unallocate all "untracked" stock + """ + + allocations = BuildItem.objects.filter( + build=self, + install_into=None + ) + + if part: + allocations = allocations.filter(stock_item__part=part) + + allocations.delete() + + @transaction.atomic + def unallocateAll(self): + """ + Deletes all stock allocations for this build. + """ + + allocations = BuildItem.objects.filter(build=self) + allocations.delete() @transaction.atomic @@ -679,13 +745,13 @@ class Build(MPTTModel): raise ValidationError(_("Build output does not match Build Order")) # Unallocate all build items against the output - self.unallocateStock(output) + self.unallocateOutput(output) # Remove the build output from the database output.delete() @transaction.atomic - def autoAllocate(self, output): + def autoAllocate(self): """ Run auto-allocation routine to allocate StockItems to this Build. @@ -702,7 +768,7 @@ class Build(MPTTModel): See: getAutoAllocations() """ - allocations = self.getAutoAllocations(output) + allocations = self.getAutoAllocations() for item in allocations: # Create a new allocation @@ -710,11 +776,29 @@ class Build(MPTTModel): build=self, stock_item=item['stock_item'], quantity=item['quantity'], - install_into=output, + install_into=None ) build_item.save() + @transaction.atomic + def subtractUntrackedStock(self, user): + """ + Called when the Build is marked as "complete", + this function removes the allocated untracked items from stock. + """ + + items = self.allocated_stock.filter( + stock_item__part__trackable=False + ) + + # Remove stock + for item in items: + item.complete_allocation(user) + + # Delete allocation + items.all().delete() + @transaction.atomic def completeBuildOutput(self, output, user, **kwargs): """ @@ -726,6 +810,7 @@ class Build(MPTTModel): # Select the location for the build output location = kwargs.get('location', self.destination) + status = kwargs.get('status', StockStatus.OK) # List the allocated BuildItem objects for the given output allocated_items = output.items_to_install.all() @@ -733,9 +818,7 @@ class Build(MPTTModel): for build_item in allocated_items: # TODO: This is VERY SLOW as each deletion from the database takes ~1 second to complete - # TODO: Use celery / redis to offload the actual object deletion... - # REF: https://www.botreetechnologies.com/blog/implementing-celery-using-django-for-background-task-processing - # REF: https://code.tutsplus.com/tutorials/using-celery-with-django-for-background-task-processing--cms-28732 + # TODO: Use the background worker process to handle this task! # Complete the allocation of stock for that item build_item.complete_allocation(user) @@ -747,6 +830,7 @@ class Build(MPTTModel): output.build = self output.is_building = False output.location = location + output.status = status output.save() @@ -779,7 +863,7 @@ class Build(MPTTModel): if output: quantity *= output.quantity else: - quantity *= self.remaining + quantity *= self.quantity return quantity @@ -807,7 +891,13 @@ class Build(MPTTModel): allocations = self.allocatedItems(part, output) - allocated = allocations.aggregate(q=Coalesce(Sum('quantity'), 0)) + allocated = allocations.aggregate( + q=Coalesce( + Sum('quantity'), + 0, + output_field=models.DecimalField(), + ) + ) return allocated['q'] @@ -828,19 +918,39 @@ class Build(MPTTModel): return self.unallocatedQuantity(part, output) == 0 - def isFullyAllocated(self, output): + def isFullyAllocated(self, output, verbose=False): """ Returns True if the particular build output is fully allocated. """ - for bom_item in self.bom_items: + # If output is not specified, we are talking about "untracked" items + if output is None: + bom_items = self.untracked_bom_items + else: + bom_items = self.tracked_bom_items + + fully_allocated = True + + for bom_item in bom_items: part = bom_item.sub_part if not self.isPartFullyAllocated(part, output): - return False + fully_allocated = False + + if verbose: + print(f"Part {part} is not fully allocated for output {output}") + else: + break # All parts must be fully allocated! - return True + return fully_allocated + + def areUntrackedPartsFullyAllocated(self): + """ + Returns True if the un-tracked parts are fully allocated for this BuildOrder + """ + + return self.isFullyAllocated(None) def allocatedParts(self, output): """ @@ -849,7 +959,13 @@ class Build(MPTTModel): allocated = [] - for bom_item in self.bom_items: + # If output is not specified, we are talking about "untracked" items + if output is None: + bom_items = self.untracked_bom_items + else: + bom_items = self.tracked_bom_items + + for bom_item in bom_items: part = bom_item.sub_part if self.isPartFullyAllocated(part, output): @@ -864,7 +980,13 @@ class Build(MPTTModel): unallocated = [] - for bom_item in self.bom_items: + # If output is not specified, we are talking about "untracked" items + if output is None: + bom_items = self.untracked_bom_items + else: + bom_items = self.tracked_bom_items + + for bom_item in bom_items: part = bom_item.sub_part if not self.isPartFullyAllocated(part, output): @@ -1014,10 +1136,12 @@ class BuildItem(models.Model): errors = {} - if not self.install_into: - raise ValidationError(_('Build item must specify a build output')) - try: + + # If the 'part' is trackable, then the 'install_into' field must be set! + if self.stock_item.part and self.stock_item.part.trackable and not self.install_into: + raise ValidationError(_('Build item must specify a build output, as master part is marked as trackable')) + # Allocated part must be in the BOM for the master part if self.stock_item.part not in self.build.part.getRequiredParts(recursive=False): errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name)] diff --git a/InvenTree/build/templates/build/allocate.html b/InvenTree/build/templates/build/allocate.html index 1b66f17d0d..dee90a26a0 100644 --- a/InvenTree/build/templates/build/allocate.html +++ b/InvenTree/build/templates/build/allocate.html @@ -12,48 +12,41 @@ {% endblock %} {% block heading %} -{% trans "Incomplete Build Ouputs" %} +{% trans "Allocate Stock to Build" %} {% endblock %} {% block details %} -{% if build.is_complete %} -
- {% trans "Build order has been completed" %} -
-{% else %} +{% if build.has_untracked_bom_items %} +{% if build.active %}
- {% if build.active %} - - - {% endif %} +
- -
-{% if build.incomplete_outputs %} -
- {% for item in build.incomplete_outputs %} - {% include "build/allocation_card.html" with item=item %} - {% endfor %} +{% if build.areUntrackedPartsFullyAllocated %} +
+ {% trans "Untracked stock has been fully allocated for this Build Order" %}
{% else %} -
- {% trans "Create a new build output" %}
- {% trans "No incomplete build outputs remain." %}
- {% trans "Create a new build output using the button above" %} +
+ {% trans "Untracked stock has not been fully allocated for this Build Order" %} +
+{% endif %} +{% endif %} +
+{% else %} +
+ {% trans "This Build Order does not have any associated untracked BOM items" %}
{% endif %} - -{% endif %} - {% endblock %} {% block js_ready %} @@ -66,19 +59,17 @@ part: {{ build.part.pk }}, }; - {% for item in build.incomplete_outputs %} - // Get the build output as a javascript object - inventreeGet('{% url 'api-stock-detail' item.pk %}', {}, - { - success: function(response) { - loadBuildOutputAllocationTable(buildInfo, response); - } - } - ); - {% endfor %} + {% if build.has_untracked_bom_items %} + // Load allocation table for un-tracked parts + loadBuildOutputAllocationTable(buildInfo, null); + {% endif %} + + function reloadTable() { + $('#allocation-table-untracked').bootstrapTable('refresh'); + } {% if build.active %} - $("#btn-allocate").on('click', function() { + $("#btn-auto-allocate").on('click', function() { launchModalForm( "{% url 'build-auto-allocate' build.id %}", { @@ -86,20 +77,12 @@ } ); }); - + $('#btn-unallocate').on('click', function() { launchModalForm( "{% url 'build-unallocate' build.id %}", { - reload: true, - } - ); - }); - - $('#btn-create-output').click(function() { - launchModalForm('{% url "build-output-create" build.id %}', - { - reload: true, + success: reloadTable, } ); }); diff --git a/InvenTree/build/templates/build/allocation_card.html b/InvenTree/build/templates/build/allocation_card.html index 650257bb0d..3ce4a52aeb 100644 --- a/InvenTree/build/templates/build/allocation_card.html +++ b/InvenTree/build/templates/build/allocation_card.html @@ -7,23 +7,31 @@ {% endif %} +