diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index e2bdae1d8f..7af7c82914 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -141,3 +141,15 @@ class InvenTreeAPITestCase(APITestCase): self.assertEqual(response.status_code, expected_code) return response + + def options(self, url, expected_code=None): + """ + Issue an OPTIONS request + """ + + response = self.client.options(url, format='json') + + if expected_code is not None: + self.assertEqual(response.status_code, expected_code) + + return response diff --git a/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py new file mode 100644 index 0000000000..07e700a1cf --- /dev/null +++ b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py @@ -0,0 +1,70 @@ +""" +Custom management command to rebuild thumbnail images + +- May be required after importing a new dataset, for example +""" + +import os +import logging + +from PIL import UnidentifiedImageError + +from django.core.management.base import BaseCommand +from django.conf import settings +from django.db.utils import OperationalError, ProgrammingError + +from company.models import Company +from part.models import Part + + +logger = logging.getLogger("inventree-thumbnails") + + +class Command(BaseCommand): + """ + Rebuild all thumbnail images + """ + + def rebuild_thumbnail(self, model): + """ + Rebuild the thumbnail specified by the "image" field of the provided model + """ + + if not model.image: + return + + img = model.image + url = img.thumbnail.name + loc = os.path.join(settings.MEDIA_ROOT, url) + + if not os.path.exists(loc): + logger.info(f"Generating thumbnail image for '{img}'") + + try: + model.image.render_variations(replace=False) + except FileNotFoundError: + logger.error(f"ERROR: Image file '{img}' is missing") + except UnidentifiedImageError: + logger.error(f"ERROR: Image file '{img}' is not a valid image") + + def handle(self, *args, **kwargs): + + logger.setLevel(logging.INFO) + + logger.info("Rebuilding Part thumbnails") + + for part in Part.objects.exclude(image=None): + try: + self.rebuild_thumbnail(part) + except (OperationalError, ProgrammingError): + logger.error("ERROR: Database read error.") + break + + logger.info("Rebuilding Company thumbnails") + + for company in Company.objects.exclude(image=None): + try: + self.rebuild_thumbnail(company) + except (OperationalError, ProgrammingError): + logger.error("ERROR: abase read error.") + break diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index f309f85e66..1d9423371f 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -10,11 +10,15 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" -INVENTREE_API_VERSION = 12 +INVENTREE_API_VERSION = 13 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v13 -> 2021-10-05 + - Adds API endpoint to allocate stock items against a BuildOrder + - Updates StockItem API with improved filtering against BomItem data + v12 -> 2021-09-07 - Adds API endpoint to receive stock items against a PurchaseOrder diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index eb6d42cc6d..cc897d6ec9 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -5,10 +5,12 @@ JSON API for the Build app # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.utils.translation import ugettext_lazy as _ + from django.conf.urls import url, include -from rest_framework import filters -from rest_framework import generics +from rest_framework import filters, generics +from rest_framework.serializers import ValidationError from django_filters.rest_framework import DjangoFilterBackend from django_filters import rest_framework as rest_filters @@ -19,6 +21,7 @@ from InvenTree.status_codes import BuildStatus from .models import Build, BuildItem, BuildOrderAttachment from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer +from .serializers import BuildAllocationSerializer class BuildFilter(rest_filters.FilterSet): @@ -92,7 +95,7 @@ class BuildList(generics.ListCreateAPIView): as some of the fields don't natively play nicely with DRF """ - queryset = super().get_queryset().prefetch_related('part') + queryset = super().get_queryset().select_related('part') queryset = BuildSerializer.annotate_queryset(queryset) @@ -181,6 +184,58 @@ class BuildDetail(generics.RetrieveUpdateAPIView): serializer_class = BuildSerializer +class BuildAllocate(generics.CreateAPIView): + """ + API endpoint to allocate stock items to a build order + + - The BuildOrder object is specified by the URL + - Items to allocate are specified as a list called "items" with the following options: + - bom_item: pk value of a given BomItem object (must match the part associated with this build) + - stock_item: pk value of a given StockItem object + - quantity: quantity to allocate + - output: StockItem (build order output) to allocate stock against (optional) + """ + + queryset = Build.objects.none() + + serializer_class = BuildAllocationSerializer + + def get_build(self): + """ + Returns the BuildOrder associated with this API endpoint + """ + + pk = self.kwargs.get('pk', None) + + try: + build = Build.objects.get(pk=pk) + except (Build.DoesNotExist, ValueError): + raise ValidationError(_("Matching build order does not exist")) + + return build + + def get_serializer_context(self): + """ + Provide the Build object to the serializer context + """ + + context = super().get_serializer_context() + + context['build'] = self.get_build() + context['request'] = self.request + + return context + + +class BuildItemDetail(generics.RetrieveUpdateDestroyAPIView): + """ + API endpoint for detail view of a BuildItem object + """ + + queryset = BuildItem.objects.all() + serializer_class = BuildItemSerializer + + class BuildItemList(generics.ListCreateAPIView): """ API endpoint for accessing a list of BuildItem objects @@ -210,9 +265,9 @@ class BuildItemList(generics.ListCreateAPIView): query = BuildItem.objects.all() - query = query.select_related('stock_item') - query = query.prefetch_related('stock_item__part') - query = query.prefetch_related('stock_item__part__category') + query = query.select_related('stock_item__location') + query = query.select_related('stock_item__part') + query = query.select_related('stock_item__part__category') return query @@ -282,16 +337,20 @@ build_api_urls = [ # Attachments url(r'^attachment/', include([ url(r'^(?P\d+)/', BuildAttachmentDetail.as_view(), name='api-build-attachment-detail'), - url('^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'), + url(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'), ])), # Build Items url(r'^item/', include([ - url('^.*$', BuildItemList.as_view(), name='api-build-item-list') + url(r'^(?P\d+)/', BuildItemDetail.as_view(), name='api-build-item-detail'), + url(r'^.*$', BuildItemList.as_view(), name='api-build-item-list'), ])), # Build Detail - url(r'^(?P\d+)/', BuildDetail.as_view(), name='api-build-detail'), + url(r'^(?P\d+)/', include([ + url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), + url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'), + ])), # Build List url(r'^.*$', BuildList.as_view(), name='api-build-list'), diff --git a/InvenTree/build/fixtures/build.yaml b/InvenTree/build/fixtures/build.yaml index cc645f9696..1506c9402a 100644 --- a/InvenTree/build/fixtures/build.yaml +++ b/InvenTree/build/fixtures/build.yaml @@ -3,7 +3,7 @@ - model: build.build pk: 1 fields: - part: 25 + part: 100 # Build against part 100 "Bob" batch: 'B1' reference: "0001" title: 'Building 7 parts' diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index e2ca7c3f75..b3f6cd92de 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -15,7 +15,7 @@ from InvenTree.fields import DatePickerFormField from InvenTree.status_codes import StockStatus -from .models import Build, BuildItem +from .models import Build from stock.models import StockLocation, StockItem @@ -163,18 +163,6 @@ class UnallocateBuildForm(HelperForm): ] -class AutoAllocateForm(HelperForm): - """ Form for auto-allocation of stock to a build """ - - confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm stock allocation')) - - class Meta: - model = Build - fields = [ - 'confirm', - ] - - class CompleteBuildForm(HelperForm): """ Form for marking a build as complete @@ -256,22 +244,3 @@ class CancelBuildForm(HelperForm): fields = [ 'confirm_cancel' ] - - -class EditBuildItemForm(HelperForm): - """ - Form for creating (or editing) a BuildItem object. - """ - - quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'), help_text=_('Select quantity of stock to allocate')) - - part_id = forms.IntegerField(required=False, widget=forms.HiddenInput()) - - class Meta: - model = BuildItem - fields = [ - 'build', - 'stock_item', - 'quantity', - 'install_into', - ] diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 084f9ab2db..9a7b40b52f 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -4,12 +4,14 @@ Build database model definitions # -*- coding: utf-8 -*- from __future__ import unicode_literals +import decimal import os from datetime import datetime -from django.contrib.auth.models import User from django.utils.translation import ugettext_lazy as _ + +from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.urls import reverse @@ -584,86 +586,6 @@ class Build(MPTTModel): self.status = BuildStatus.CANCELLED self.save() - def getAutoAllocations(self): - """ - Return a list of StockItem objects which will be allocated - using the 'AutoAllocate' function. - - For each item in the BOM for the attached Part, - the following tests must *all* evaluate to True, - for the part to be auto-allocated: - - - The sub_item in the BOM line must *not* be trackable - - There is only a single stock item available (which has not already been allocated to this build) - - The stock item has an availability greater than zero - - Returns: - A list object containing the StockItem objects to be allocated (and the quantities). - Each item in the list is a dict as follows: - { - 'stock_item': stock_item, - 'quantity': stock_quantity, - } - """ - - allocations = [] - - """ - Iterate through each item in the BOM - """ - - for bom_item in self.bom_items: - - 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, None): - continue - - # How many parts are required to complete the output? - required = self.unallocatedQuantity(part, None) - - # Grab a list of stock items which are available - stock_items = self.availableStockItems(part, None) - - # Ensure that the available stock items are in the correct location - if self.take_from is not None: - # Filter for stock that is located downstream of the designated location - stock_items = stock_items.filter(location__in=[loc for loc in self.take_from.getUniqueChildren()]) - - # Only one StockItem to choose from? Default to that one! - if stock_items.count() == 1: - stock_item = stock_items[0] - - # Double check that we have not already allocated this stock-item against this build - build_items = BuildItem.objects.filter( - build=self, - stock_item=stock_item, - ) - - if len(build_items) > 0: - continue - - # How many items are actually available? - if stock_item.quantity > 0: - - # Only take as many as are available - if stock_item.quantity < required: - required = stock_item.quantity - - allocation = { - 'stock_item': stock_item, - 'quantity': required, - } - - allocations.append(allocation) - - return allocations - @transaction.atomic def unallocateOutput(self, output, part=None): """ @@ -803,37 +725,6 @@ class Build(MPTTModel): # Remove the build output from the database output.delete() - @transaction.atomic - def autoAllocate(self): - """ - Run auto-allocation routine to allocate StockItems to this Build. - - Args: - output: If specified, only auto-allocate against the given built output - - Returns a list of dict objects with keys like: - - { - 'stock_item': item, - 'quantity': quantity, - } - - See: getAutoAllocations() - """ - - allocations = self.getAutoAllocations() - - for item in allocations: - # Create a new allocation - build_item = BuildItem( - build=self, - stock_item=item['stock_item'], - quantity=item['quantity'], - install_into=None - ) - - build_item.save() - @transaction.atomic def subtractUntrackedStock(self, user): """ @@ -1165,8 +1056,10 @@ class BuildItem(models.Model): Attributes: build: Link to a Build object + bom_item: Link to a BomItem object (may or may not point to the same part as the build) stock_item: Link to a StockItem object quantity: Number of units allocated + install_into: Destination stock item (or None) """ @staticmethod @@ -1185,35 +1078,13 @@ class BuildItem(models.Model): def save(self, *args, **kwargs): - self.validate_unique() self.clean() super().save() - def validate_unique(self, exclude=None): - """ - Test that this BuildItem object is "unique". - Essentially we do not want a stock_item being allocated to a Build multiple times. - """ - - super().validate_unique(exclude) - - items = BuildItem.objects.exclude(id=self.id).filter( - build=self.build, - stock_item=self.stock_item, - install_into=self.install_into - ) - - if items.exists(): - msg = _("BuildItem must be unique for build, stock_item and install_into") - raise ValidationError({ - 'build': msg, - 'stock_item': msg, - 'install_into': msg - }) - def clean(self): - """ Check validity of the BuildItem model. + """ + Check validity of this BuildItem instance. The following checks are performed: - StockItem.part must be in the BOM of the Part object referenced by Build @@ -1224,8 +1095,6 @@ class BuildItem(models.Model): super().clean() - errors = {} - try: # If the 'part' is trackable, then the 'install_into' field must be set! @@ -1234,29 +1103,39 @@ class BuildItem(models.Model): # Allocated quantity cannot exceed available stock quantity if self.quantity > self.stock_item.quantity: - errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})").format( - n=normalize(self.quantity), - q=normalize(self.stock_item.quantity) - )] + + q = normalize(self.quantity) + a = normalize(self.stock_item.quantity) + + raise ValidationError({ + 'quantity': _(f'Allocated quantity ({q}) must not execed available stock quantity ({a})') + }) # Allocated quantity cannot cause the stock item to be over-allocated - if self.stock_item.quantity - self.stock_item.allocation_count() + self.quantity < self.quantity: - errors['quantity'] = _('StockItem is over-allocated') + available = decimal.Decimal(self.stock_item.quantity) + allocated = decimal.Decimal(self.stock_item.allocation_count()) + quantity = decimal.Decimal(self.quantity) + + if available - allocated + quantity < quantity: + raise ValidationError({ + 'quantity': _('Stock item is over-allocated') + }) # Allocated quantity must be positive if self.quantity <= 0: - errors['quantity'] = _('Allocation quantity must be greater than zero') + raise ValidationError({ + 'quantity': _('Allocation quantity must be greater than zero'), + }) # Quantity must be 1 for serialized stock if self.stock_item.serialized and not self.quantity == 1: - errors['quantity'] = _('Quantity must be 1 for serialized stock') + raise ValidationError({ + 'quantity': _('Quantity must be 1 for serialized stock') + }) except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist): pass - if len(errors) > 0: - raise ValidationError(errors) - """ Attempt to find the "BomItem" which links this BuildItem to the build. @@ -1269,7 +1148,7 @@ class BuildItem(models.Model): """ A BomItem object has already been assigned. This is valid if: - a) It points to the same "part" as the referened build + a) It points to the same "part" as the referenced build b) Either: i) The sub_part points to the same part as the referenced StockItem ii) The BomItem allows variants and the part referenced by the StockItem @@ -1309,7 +1188,7 @@ class BuildItem(models.Model): if not bom_item_valid: raise ValidationError({ - 'stock_item': _("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name) + 'stock_item': _("Selected stock item not found in BOM") }) @transaction.atomic diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 69e3a7aed0..53e71dbd27 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -5,16 +5,25 @@ JSON serializers for Build API # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.db import transaction +from django.core.exceptions import ValidationError as DjangoValidationError +from django.utils.translation import ugettext_lazy as _ + from django.db.models import Case, When, Value from django.db.models import BooleanField from rest_framework import serializers +from rest_framework.serializers import ValidationError from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief -from stock.serializers import StockItemSerializerBrief -from stock.serializers import LocationSerializer +import InvenTree.helpers + +from stock.models import StockItem +from stock.serializers import StockItemSerializerBrief, LocationSerializer + +from part.models import BomItem from part.serializers import PartSerializer, PartBriefSerializer from users.serializers import OwnerSerializer @@ -22,7 +31,9 @@ from .models import Build, BuildItem, BuildOrderAttachment class BuildSerializer(InvenTreeModelSerializer): - """ Serializes a Build object """ + """ + Serializes a Build object + """ url = serializers.CharField(source='get_absolute_url', read_only=True) status_text = serializers.CharField(source='get_status_display', read_only=True) @@ -109,6 +120,170 @@ class BuildSerializer(InvenTreeModelSerializer): ] +class BuildAllocationItemSerializer(serializers.Serializer): + """ + A serializer for allocating a single stock item against a build order + """ + + bom_item = serializers.PrimaryKeyRelatedField( + queryset=BomItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('BOM Item'), + ) + + def validate_bom_item(self, bom_item): + + build = self.context['build'] + + # BomItem must point to the same 'part' as the parent build + if build.part != bom_item.part: + raise ValidationError(_("bom_item.part must point to the same part as the build order")) + + return bom_item + + stock_item = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Stock Item'), + ) + + def validate_stock_item(self, stock_item): + + if not stock_item.in_stock: + raise ValidationError(_("Item must be in stock")) + + return stock_item + + quantity = serializers.DecimalField( + max_digits=15, + decimal_places=5, + min_value=0, + required=True + ) + + def validate_quantity(self, quantity): + + if quantity <= 0: + raise ValidationError(_("Quantity must be greater than zero")) + + return quantity + + output = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.filter(is_building=True), + many=False, + allow_null=True, + required=False, + label=_('Build Output'), + ) + + class Meta: + fields = [ + 'bom_item', + 'stock_item', + 'quantity', + 'output', + ] + + def validate(self, data): + + super().validate(data) + + bom_item = data['bom_item'] + stock_item = data['stock_item'] + quantity = data['quantity'] + output = data.get('output', None) + + # build = self.context['build'] + + # TODO: Check that the "stock item" is valid for the referenced "sub_part" + # Note: Because of allow_variants options, it may not be a direct match! + + # Check that the quantity does not exceed the available amount from the stock item + q = stock_item.unallocated_quantity() + + if quantity > q: + + q = InvenTree.helpers.clean_decimal(q) + + raise ValidationError({ + 'quantity': _(f"Available quantity ({q}) exceeded") + }) + + # Output *must* be set for trackable parts + if output is None and bom_item.sub_part.trackable: + raise ValidationError({ + 'output': _('Build output must be specified for allocation of tracked parts') + }) + + # Output *cannot* be set for un-tracked parts + if output is not None and not bom_item.sub_part.trackable: + + raise ValidationError({ + 'output': _('Build output cannot be specified for allocation of untracked parts') + }) + + return data + + +class BuildAllocationSerializer(serializers.Serializer): + """ + DRF serializer for allocation stock items against a build order + """ + + items = BuildAllocationItemSerializer(many=True) + + class Meta: + fields = [ + 'items', + ] + + def validate(self, data): + """ + Validation + """ + + super().validate(data) + + items = data.get('items', []) + + if len(items) == 0: + raise ValidationError(_('Allocation items must be provided')) + + return data + + def save(self): + + data = self.validated_data + + items = data.get('items', []) + + build = self.context['build'] + + with transaction.atomic(): + for item in items: + bom_item = item['bom_item'] + stock_item = item['stock_item'] + quantity = item['quantity'] + output = item.get('output', None) + + try: + # Create a new BuildItem to allocate stock + BuildItem.objects.create( + build=build, + bom_item=bom_item, + stock_item=stock_item, + quantity=quantity, + install_into=output + ) + except (ValidationError, DjangoValidationError) as exc: + # Catch model errors and re-throw as DRF errors + raise ValidationError(detail=serializers.as_serializer_error(exc)) + + class BuildItemSerializer(InvenTreeModelSerializer): """ Serializes a BuildItem object """ diff --git a/InvenTree/build/templates/build/auto_allocate.html b/InvenTree/build/templates/build/auto_allocate.html deleted file mode 100644 index 2f2c7bbca7..0000000000 --- a/InvenTree/build/templates/build/auto_allocate.html +++ /dev/null @@ -1,43 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} -{% load inventree_extras %} -{% block pre_form_content %} - -{{ block.super }} - -
- {% trans "Automatically Allocate Stock" %}
- {% trans "The following stock items will be allocated to the specified build output" %} -
-{% if allocations %} - - - - - - - -{% for item in allocations %} - - - - - - -{% endfor %} -
{% trans "Part" %}{% trans "Quantity" %}{% trans "Location" %}
- {% include "hover_image.html" with image=item.stock_item.part.image hover=True %} - - {{ item.stock_item.part.full_name }}
- {{ item.stock_item.part.description }} -
{% decimal item.quantity %}{{ item.stock_item.location }}
- -{% else %} -
- {% trans "No stock items found that can be automatically allocated to this build" %} -
- {% trans "Stock items will have to be manually allocated" %} -
-{% endif %} - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/create_build_item.html b/InvenTree/build/templates/build/create_build_item.html deleted file mode 100644 index cc23bd49a9..0000000000 --- a/InvenTree/build/templates/build/create_build_item.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} -
-

- {% trans "Select a stock item to allocate to the selected build output" %} -

- {% if output %} -

- {% blocktrans %}The allocated stock will be installed into the following build output:
{{output}}{% endblocktrans %} -

- {% endif %} -
-{% if no_stock %} - -{% endif %} -{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/delete_build_item.html b/InvenTree/build/templates/build/delete_build_item.html deleted file mode 100644 index d5cc285466..0000000000 --- a/InvenTree/build/templates/build/delete_build_item.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "modal_delete_form.html" %} -{% load i18n %} -{% load inventree_extras %} - -{% block pre_form_content %} -
-

- {% trans "Are you sure you want to unallocate this stock?" %} -

-

- {% trans "The selected stock will be unallocated from the build output" %} -

-
-{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index cf3a4bece1..8fb259f8a4 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -170,7 +170,7 @@ {% if build.active %}
{% endif %} {% endif %} -
+
+
+
+ +
+ +
+
+
+
+
{% else %}
{% trans "This Build Order does not have any associated untracked BOM items" %} @@ -306,6 +318,9 @@ var buildInfo = { quantity: {{ build.quantity }}, completed: {{ build.completed }}, part: {{ build.part.pk }}, + {% if build.take_from %} + source_location: {{ build.take_from.pk }}, + {% endif %} }; {% for item in build.incomplete_outputs %} @@ -401,13 +416,6 @@ $('#edit-notes').click(function() { }); }); -var buildInfo = { - pk: {{ build.pk }}, - quantity: {{ build.quantity }}, - completed: {{ build.completed }}, - part: {{ build.part.pk }}, -}; - {% if build.has_untracked_bom_items %} // Load allocation table for un-tracked parts loadBuildOutputAllocationTable(buildInfo, null); @@ -419,12 +427,38 @@ function reloadTable() { {% if build.active %} $("#btn-auto-allocate").on('click', function() { - launchModalForm( - "{% url 'build-auto-allocate' build.id %}", - { - success: reloadTable, + + var bom_items = $("#allocation-table-untracked").bootstrapTable("getData"); + + var incomplete_bom_items = []; + + bom_items.forEach(function(bom_item) { + if (bom_item.required > bom_item.allocated) { + incomplete_bom_items.push(bom_item); } - ); + }); + + if (incomplete_bom_items.length == 0) { + showAlertDialog( + '{% trans "Allocation Complete" %}', + '{% trans "All untracked stock items have been allocated" %}', + ); + } else { + + allocateStockToBuild( + {{ build.pk }}, + {{ build.part.pk }}, + incomplete_bom_items, + { + {% if build.take_from %} + source_location: {{ build.take_from.pk }}, + {% endif %} + success: function(data) { + $('#allocation-table-untracked').bootstrapTable('refresh'); + } + } + ); + } }); $('#btn-unallocate').on('click', function() { @@ -436,6 +470,25 @@ $('#btn-unallocate').on('click', function() { ); }); +$('#allocate-selected-items').click(function() { + + var bom_items = $("#allocation-table-untracked").bootstrapTable("getSelections"); + + allocateStockToBuild( + {{ build.pk }}, + {{ build.part.pk }}, + bom_items, + { + {% if build.take_from %} + source_location: {{ build.take_from.pk }}, + {% endif %} + success: function(data) { + $('#allocation-table-untracked').bootstrapTable('refresh'); + } + } + ); +}); + $("#btn-order-parts").click(function() { launchModalForm("/order/purchase-order/order-parts/", { data: { diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index a1d0c3df9f..017f0126c5 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta from django.urls import reverse from part.models import Part -from build.models import Build +from build.models import Build, BuildItem from InvenTree.status_codes import BuildStatus from InvenTree.api_tester import InvenTreeAPITestCase @@ -23,6 +23,7 @@ class BuildAPITest(InvenTreeAPITestCase): 'location', 'bom', 'build', + 'stock', ] # Required roles to access Build API endpoints @@ -36,6 +37,192 @@ class BuildAPITest(InvenTreeAPITestCase): super().setUp() +class BuildAllocationTest(BuildAPITest): + """ + Unit tests for allocation of stock items against a build order. + + For this test, we will be using Build ID=1; + + - This points to Part 100 (see fixture data in part.yaml) + - This Part already has a BOM with 4 items (see fixture data in bom.yaml) + - There are no BomItem objects yet created for this build + + """ + + def setUp(self): + + super().setUp() + + self.assignRole('build.add') + self.assignRole('build.change') + + self.url = reverse('api-build-allocate', kwargs={'pk': 1}) + + self.build = Build.objects.get(pk=1) + + # Record number of build items which exist at the start of each test + self.n = BuildItem.objects.count() + + def test_build_data(self): + """ + Check that our assumptions about the particular BuildOrder are correct + """ + + self.assertEqual(self.build.part.pk, 100) + + # There should be 4x BOM items we can use + self.assertEqual(self.build.part.bom_items.count(), 4) + + # No items yet allocated to this build + self.assertEqual(self.build.allocated_stock.count(), 0) + + def test_get(self): + """ + A GET request to the endpoint should return an error + """ + + self.get(self.url, expected_code=405) + + def test_options(self): + """ + An OPTIONS request to the endpoint should return information about the endpoint + """ + + response = self.options(self.url, expected_code=200) + + self.assertIn("API endpoint to allocate stock items to a build order", str(response.data)) + + def test_empty(self): + """ + Test without any POST data + """ + + # Initially test with an empty data set + data = self.post(self.url, {}, expected_code=400).data + + self.assertIn('This field is required', str(data['items'])) + + # Now test but with an empty items list + data = self.post( + self.url, + { + "items": [] + }, + expected_code=400 + ).data + + self.assertIn('Allocation items must be provided', str(data)) + + # No new BuildItem objects have been created during this test + self.assertEqual(self.n, BuildItem.objects.count()) + + def test_missing(self): + """ + Test with missing data + """ + + # Missing quantity + data = self.post( + self.url, + { + "items": [ + { + "bom_item": 1, # M2x4 LPHS + "stock_item": 2, # 5,000 screws available + } + ] + }, + expected_code=400 + ).data + + self.assertIn('This field is required', str(data["items"][0]["quantity"])) + + # Missing bom_item + data = self.post( + self.url, + { + "items": [ + { + "stock_item": 2, + "quantity": 5000, + } + ] + }, + expected_code=400 + ).data + + self.assertIn("This field is required", str(data["items"][0]["bom_item"])) + + # Missing stock_item + data = self.post( + self.url, + { + "items": [ + { + "bom_item": 1, + "quantity": 5000, + } + ] + }, + expected_code=400 + ).data + + self.assertIn("This field is required", str(data["items"][0]["stock_item"])) + + # No new BuildItem objects have been created during this test + self.assertEqual(self.n, BuildItem.objects.count()) + + def test_invalid_bom_item(self): + """ + Test by passing an invalid BOM item + """ + + data = self.post( + self.url, + { + "items": [ + { + "bom_item": 5, + "stock_item": 11, + "quantity": 500, + } + ] + }, + expected_code=400 + ).data + + self.assertIn('must point to the same part', str(data)) + + def test_valid_data(self): + """ + Test with valid data. + This should result in creation of a new BuildItem object + """ + + self.post( + self.url, + { + "items": [ + { + "bom_item": 1, + "stock_item": 2, + "quantity": 5000, + } + ] + }, + expected_code=201 + ) + + # A new BuildItem should have been created + self.assertEqual(self.n + 1, BuildItem.objects.count()) + + allocation = BuildItem.objects.last() + + self.assertEqual(allocation.quantity, 5000) + self.assertEqual(allocation.bom_item.pk, 1) + self.assertEqual(allocation.stock_item.pk, 2) + + class BuildListTest(BuildAPITest): """ Tests for the BuildOrder LIST API diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index 30fe8c488b..04b46bbd26 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -269,25 +269,6 @@ class BuildTest(TestCase): self.assertTrue(self.build.areUntrackedPartsFullyAllocated()) - def test_auto_allocate(self): - """ - Test auto-allocation functionality against the build outputs. - - Note: auto-allocations only work for un-tracked stock! - """ - - allocations = self.build.getAutoAllocations() - - self.assertEqual(len(allocations), 1) - - self.build.autoAllocate() - self.assertEqual(BuildItem.objects.count(), 1) - - # Check that one un-tracked part has been fully allocated to the build - self.assertTrue(self.build.isPartFullyAllocated(self.sub_part_2, None)) - - self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, None)) - def test_cancel(self): """ Test cancellation of the build diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py index b5e5406f69..93c6bfd511 100644 --- a/InvenTree/build/tests.py +++ b/InvenTree/build/tests.py @@ -172,7 +172,7 @@ class TestBuildAPI(APITestCase): # Filter by 'part' status response = self.client.get(url, {'part': 25}, format='json') - self.assertEqual(len(response.data), 2) + self.assertEqual(len(response.data), 1) # Filter by an invalid part response = self.client.get(url, {'part': 99999}, format='json') @@ -252,34 +252,6 @@ class TestBuildViews(TestCase): self.assertIn(build.title, content) - def test_build_item_create(self): - """ Test the BuildItem creation view (ajax form) """ - - url = reverse('build-item-create') - - # Try without a part specified - response = self.client.get(url, {'build': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Try with an invalid build ID - response = self.client.get(url, {'build': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Try with a valid part specified - response = self.client.get(url, {'build': 1, 'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # Try with an invalid part specified - response = self.client.get(url, {'build': 1, 'part': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - def test_build_item_edit(self): - """ Test the BuildItem edit view (ajax form) """ - - # TODO - # url = reverse('build-item-edit') - pass - def test_build_output_complete(self): """ Test the build output completion form diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index 9814dc83f7..050c32209b 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -12,7 +12,6 @@ build_detail_urls = [ url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'), url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'), url(r'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'), - url(r'^auto-allocate/', views.BuildAutoAllocate.as_view(), name='build-auto-allocate'), url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'), url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'), @@ -20,13 +19,6 @@ build_detail_urls = [ ] build_urls = [ - url(r'item/', include([ - url(r'^(?P\d+)/', include([ - url('^edit/', views.BuildItemEdit.as_view(), name='build-item-edit'), - url('^delete/', views.BuildItemDelete.as_view(), name='build-item-delete'), - ])), - url('^new/', views.BuildItemCreate.as_view(), name='build-item-create'), - ])), url(r'^(?P\d+)/', include(build_detail_urls)), diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index dfa655f9a4..702b3b3596 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -11,13 +11,13 @@ from django.views.generic import DetailView, ListView from django.forms import HiddenInput from part.models import Part -from .models import Build, BuildItem +from .models import Build from . import forms from stock.models import StockLocation, StockItem -from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView +from InvenTree.views import AjaxUpdateView, AjaxDeleteView from InvenTree.views import InvenTreeRoleMixin -from InvenTree.helpers import str2bool, extract_serial_numbers, normalize, isNull +from InvenTree.helpers import str2bool, extract_serial_numbers, isNull from InvenTree.status_codes import BuildStatus, StockStatus @@ -77,67 +77,6 @@ class BuildCancel(AjaxUpdateView): } -class BuildAutoAllocate(AjaxUpdateView): - """ View to auto-allocate parts for a build. - Follows a simple set of rules to automatically allocate StockItem objects. - - Ref: build.models.Build.getAutoAllocations() - """ - - model = Build - form_class = forms.AutoAllocateForm - context_object_name = 'build' - ajax_form_title = _('Allocate Stock') - ajax_template_name = 'build/auto_allocate.html' - - def get_initial(self): - """ - Initial values for the form. - """ - - initials = super().get_initial() - - return initials - - def get_context_data(self, *args, **kwargs): - """ - Get the context data for form rendering. - """ - - context = {} - - build = self.get_object() - - context['allocations'] = build.getAutoAllocations() - - context['build'] = build - - return context - - def get_form(self): - - form = super().get_form() - - return form - - def validate(self, build, form, **kwargs): - - pass - - def save(self, build, form, **kwargs): - """ - Once the form has been validated, - perform auto-allocations - """ - - build.autoAllocate() - - def get_data(self): - return { - 'success': _('Allocated stock to build output'), - } - - class BuildOutputCreate(AjaxUpdateView): """ Create a new build output (StockItem) for a given build. @@ -626,268 +565,3 @@ class BuildDelete(AjaxDeleteView): model = Build ajax_template_name = 'build/delete_build.html' ajax_form_title = _('Delete Build Order') - - -class BuildItemDelete(AjaxDeleteView): - """ View to 'unallocate' a BuildItem. - Really we are deleting the BuildItem object from the database. - """ - - model = BuildItem - ajax_template_name = 'build/delete_build_item.html' - ajax_form_title = _('Unallocate Stock') - context_object_name = 'item' - - def get_data(self): - return { - 'danger': _('Removed parts from build allocation') - } - - -class BuildItemCreate(AjaxCreateView): - """ - View for allocating a StockItem to a build output. - """ - - model = BuildItem - form_class = forms.EditBuildItemForm - ajax_template_name = 'build/create_build_item.html' - ajax_form_title = _('Allocate stock to build output') - - # The output StockItem against which the allocation is being made - output = None - - # The "part" which is being allocated to the output - part = None - - available_stock = None - - def get_context_data(self): - """ - Provide context data to the template which renders the form. - """ - - ctx = super().get_context_data() - - if self.part: - ctx['part'] = self.part - - if self.output: - ctx['output'] = self.output - - if self.available_stock: - ctx['stock'] = self.available_stock - else: - ctx['no_stock'] = True - - return ctx - - def validate(self, build_item, form, **kwargs): - """ - Extra validation steps as required - """ - - data = form.cleaned_data - - stock_item = data.get('stock_item', None) - quantity = data.get('quantity', None) - - if stock_item: - # Stock item must actually be in stock! - if not stock_item.in_stock: - form.add_error('stock_item', _('Item must be currently in stock')) - - # Check that there are enough items available - if quantity is not None: - available = stock_item.unallocated_quantity() - if quantity > available: - form.add_error('stock_item', _('Stock item is over-allocated')) - form.add_error('quantity', _('Available') + ': ' + str(normalize(available))) - else: - form.add_error('stock_item', _('Stock item must be selected')) - - def get_form(self): - """ Create Form for making / editing new Part object """ - - form = super(AjaxCreateView, self).get_form() - - self.build = None - self.part = None - self.output = None - - # If the Build object is specified, hide the input field. - # We do not want the users to be able to move a BuildItem to a different build - build_id = form['build'].value() - - if build_id is not None: - """ - If the build has been provided, hide the widget to change the build selection. - Additionally, update the allowable selections for other fields. - """ - form.fields['build'].widget = HiddenInput() - form.fields['install_into'].queryset = StockItem.objects.filter(build=build_id, is_building=True) - self.build = Build.objects.get(pk=build_id) - else: - """ - Build has *not* been selected - """ - pass - - # If the sub_part is supplied, limit to matching stock items - part_id = form['part_id'].value() - - if part_id: - try: - self.part = Part.objects.get(pk=part_id) - - except (ValueError, Part.DoesNotExist): - pass - - # If the output stock item is specified, hide the input field - output_id = form['install_into'].value() - - if output_id is not None: - - try: - self.output = StockItem.objects.get(pk=output_id) - form.fields['install_into'].widget = HiddenInput() - except (ValueError, StockItem.DoesNotExist): - pass - - else: - # If the output is not specified, but we know that the part is non-trackable, hide the install_into field - if self.part and not self.part.trackable: - form.fields['install_into'].widget = HiddenInput() - - if self.build and self.part: - available_items = self.build.availableStockItems(self.part, self.output) - - form.fields['stock_item'].queryset = available_items - - self.available_stock = form.fields['stock_item'].queryset.all() - - # If there is only a single stockitem available, select it! - if len(self.available_stock) == 1: - form.fields['stock_item'].initial = self.available_stock[0].pk - - return form - - def get_initial(self): - """ Provide initial data for BomItem. Look for the folllowing in the GET data: - - - build: pk of the Build object - - part: pk of the Part object which we are assigning - - output: pk of the StockItem object into which the allocated stock will be installed - """ - - initials = super(AjaxCreateView, self).get_initial().copy() - - build_id = self.get_param('build') - part_id = self.get_param('part') - output_id = self.get_param('install_into') - - # Reference to a Part object - part = None - - # Reference to a StockItem object - item = None - - # Reference to a Build object - build = None - - # Reference to a StockItem object - output = None - - if part_id: - try: - part = Part.objects.get(pk=part_id) - initials['part_id'] = part.pk - except Part.DoesNotExist: - pass - - if build_id: - try: - build = Build.objects.get(pk=build_id) - initials['build'] = build - except Build.DoesNotExist: - pass - - # If the output has been specified - if output_id: - try: - output = StockItem.objects.get(pk=output_id) - initials['install_into'] = output - except (ValueError, StockItem.DoesNotExist): - pass - - # Work out how much stock is required - if build and part: - required_quantity = build.unallocatedQuantity(part, output) - else: - required_quantity = None - - quantity = self.request.GET.get('quantity', None) - - if quantity is not None: - quantity = float(quantity) - elif required_quantity is not None: - quantity = required_quantity - - item_id = self.get_param('item') - - # If the request specifies a particular StockItem - if item_id: - try: - item = StockItem.objects.get(pk=item_id) - except (ValueError, StockItem.DoesNotExist): - pass - - # If a StockItem is not selected, try to auto-select one - if item is None and part is not None: - items = StockItem.objects.filter(part=part) - if items.count() == 1: - item = items.first() - - # Finally, if a StockItem is selected, ensure the quantity is not too much - if item is not None: - if quantity is None: - quantity = item.unallocated_quantity() - else: - quantity = min(quantity, item.unallocated_quantity()) - - if quantity is not None: - initials['quantity'] = quantity - - return initials - - -class BuildItemEdit(AjaxUpdateView): - """ View to edit a BuildItem object """ - - model = BuildItem - ajax_template_name = 'build/edit_build_item.html' - form_class = forms.EditBuildItemForm - ajax_form_title = _('Edit Stock Allocation') - - def get_data(self): - return { - 'info': _('Updated Build Item'), - } - - def get_form(self): - """ - Create form for editing a BuildItem. - - - Limit the StockItem options to items that match the part - """ - - form = super(BuildItemEdit, self).get_form() - - # Hide fields which we do not wish the user to edit - for field in ['build', 'stock_item']: - if form[field].value(): - form.fields[field].widget = HiddenInput() - - form.fields['install_into'].widget = HiddenInput() - - return form diff --git a/InvenTree/company/apps.py b/InvenTree/company/apps.py index 76798c5ad4..41371dd739 100644 --- a/InvenTree/company/apps.py +++ b/InvenTree/company/apps.py @@ -1,18 +1,6 @@ from __future__ import unicode_literals -import os -import logging - -from PIL import UnidentifiedImageError - from django.apps import AppConfig -from django.db.utils import OperationalError, ProgrammingError -from django.conf import settings - -from InvenTree.ready import canAppAccessDatabase - - -logger = logging.getLogger("inventree") class CompanyConfig(AppConfig): @@ -23,29 +11,4 @@ class CompanyConfig(AppConfig): This function is called whenever the Company app is loaded. """ - if canAppAccessDatabase(): - self.generate_company_thumbs() - - def generate_company_thumbs(self): - - from .models import Company - - logger.debug("Checking Company image thumbnails") - - try: - for company in Company.objects.all(): - if company.image: - url = company.image.thumbnail.name - loc = os.path.join(settings.MEDIA_ROOT, url) - - if not os.path.exists(loc): - logger.info("InvenTree: Generating thumbnail for Company '{c}'".format(c=company.name)) - try: - company.image.render_variations(replace=False) - except FileNotFoundError: - logger.warning(f"Image file '{company.image}' missing") - except UnidentifiedImageError: - logger.warning(f"Image file '{company.image}' is invalid") - except (OperationalError, ProgrammingError): - # Getting here probably meant the database was in test mode - pass + pass diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index ab6c4d7c0b..b8f54ba72b 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -7,14 +7,11 @@ from __future__ import unicode_literals from django.utils.translation import ugettext_lazy as _ from django.conf.urls import url, include -from django.db import transaction -from django.core.exceptions import ValidationError as DjangoValidationError from django_filters import rest_framework as rest_filters from rest_framework import generics from rest_framework import filters, status from rest_framework.response import Response -from rest_framework import serializers from rest_framework.serializers import ValidationError @@ -235,6 +232,7 @@ class POReceive(generics.CreateAPIView): # Pass the purchase order through to the serializer for validation context['order'] = self.get_order() + context['request'] = self.request return context @@ -252,76 +250,6 @@ class POReceive(generics.CreateAPIView): return order - def create(self, request, *args, **kwargs): - - # Which purchase order are we receiving against? - self.order = self.get_order() - - # Validate the serialized data - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - - # Receive the line items - try: - self.receive_items(serializer) - except DjangoValidationError as exc: - # Re-throw a django error as a DRF error - raise ValidationError(detail=serializers.as_serializer_error(exc)) - - headers = self.get_success_headers(serializer.data) - - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - - @transaction.atomic - def receive_items(self, serializer): - """ - Receive the items - - At this point, much of the heavy lifting has been done for us by DRF serializers! - - We have a list of "items", each a dict which contains: - - line_item: A PurchaseOrderLineItem matching this order - - location: A destination location - - quantity: A validated numerical quantity - - status: The status code for the received item - """ - - data = serializer.validated_data - - location = data['location'] - - items = data['items'] - - # Check if the location is not specified for any particular item - for item in items: - - line = item['line_item'] - - if not item.get('location', None): - # If a global location is specified, use that - item['location'] = location - - if not item['location']: - # The line item specifies a location? - item['location'] = line.get_destination() - - if not item['location']: - raise ValidationError({ - 'location': _("Destination location must be specified"), - }) - - # Now we can actually receive the items - for item in items: - - self.order.receive_line_item( - item['line_item'], - item['location'], - item['quantity'], - self.request.user, - status=item['status'], - barcode=item.get('barcode', ''), - ) - class POLineItemList(generics.ListCreateAPIView): """ API endpoint for accessing a list of POLineItem objects diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index f288f8a8e2..842d422678 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -7,7 +7,8 @@ from __future__ import unicode_literals from django.utils.translation import ugettext_lazy as _ -from django.db import models +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import models, transaction from django.db.models import Case, When, Value from django.db.models import BooleanField, ExpressionWrapper, F @@ -277,35 +278,75 @@ class POReceiveSerializer(serializers.Serializer): help_text=_('Select destination location for received items'), ) - def is_valid(self, raise_exception=False): + def validate(self, data): - super().is_valid(raise_exception) - - # Custom validation - data = self.validated_data + super().validate(data) items = data.get('items', []) if len(items) == 0: - self._errors['items'] = _('Line items must be provided') - else: - # Ensure barcodes are unique - unique_barcodes = set() + raise ValidationError({ + 'items': _('Line items must be provided') + }) + # Ensure barcodes are unique + unique_barcodes = set() + + for item in items: + barcode = item.get('barcode', '') + + if barcode: + if barcode in unique_barcodes: + raise ValidationError(_('Supplied barcode values must be unique')) + else: + unique_barcodes.add(barcode) + + return data + + def save(self): + + data = self.validated_data + + request = self.context['request'] + order = self.context['order'] + + items = data['items'] + location = data.get('location', None) + + # Check if the location is not specified for any particular item + for item in items: + + line = item['line_item'] + + if not item.get('location', None): + # If a global location is specified, use that + item['location'] = location + + if not item['location']: + # The line item specifies a location? + item['location'] = line.get_destination() + + if not item['location']: + raise ValidationError({ + 'location': _("Destination location must be specified"), + }) + + # Now we can actually receive the items into stock + with transaction.atomic(): for item in items: - barcode = item.get('barcode', '') - if barcode: - if barcode in unique_barcodes: - self._errors['items'] = _('Supplied barcode values must be unique') - break - else: - unique_barcodes.add(barcode) - - if self._errors and raise_exception: - raise ValidationError(self.errors) - - return not bool(self._errors) + try: + order.receive_line_item( + item['line_item'], + item['location'], + item['quantity'], + request.user, + status=item['status'], + barcode=item.get('barcode', ''), + ) + except (ValidationError, DjangoValidationError) as exc: + # Catch model errors and re-throw as DRF errors + raise ValidationError(detail=serializers.as_serializer_error(exc)) class Meta: fields = [ diff --git a/InvenTree/part/apps.py b/InvenTree/part/apps.py index 0c57e2c1ab..49a9f2f90c 100644 --- a/InvenTree/part/apps.py +++ b/InvenTree/part/apps.py @@ -1,13 +1,9 @@ from __future__ import unicode_literals -import os import logging from django.db.utils import OperationalError, ProgrammingError from django.apps import AppConfig -from django.conf import settings - -from PIL import UnidentifiedImageError from InvenTree.ready import canAppAccessDatabase @@ -24,40 +20,8 @@ class PartConfig(AppConfig): """ if canAppAccessDatabase(): - self.generate_part_thumbnails() self.update_trackable_status() - def generate_part_thumbnails(self): - """ - Generate thumbnail images for any Part that does not have one. - This function exists mainly for legacy support, - as any *new* image uploaded will have a thumbnail generated automatically. - """ - - from .models import Part - - logger.debug("InvenTree: Checking Part image thumbnails") - - try: - # Only check parts which have images - for part in Part.objects.exclude(image=None): - if part.image: - url = part.image.thumbnail.name - loc = os.path.join(settings.MEDIA_ROOT, url) - - if not os.path.exists(loc): - logger.info("InvenTree: Generating thumbnail for Part '{p}'".format(p=part.name)) - try: - part.image.render_variations(replace=False) - except FileNotFoundError: - logger.warning(f"Image file '{part.image}' missing") - pass - except UnidentifiedImageError: - logger.warning(f"Image file '{part.image}' is invalid") - except (OperationalError, ProgrammingError): - # Exception if the database has not been migrated yet - pass - def update_trackable_status(self): """ Check for any instances where a trackable part is used in the BOM @@ -72,7 +36,7 @@ class PartConfig(AppConfig): items = BomItem.objects.filter(part__trackable=False, sub_part__trackable=True) for item in items: - print(f"Marking part '{item.part.name}' as trackable") + logger.info(f"Marking part '{item.part.name}' as trackable") item.part.trackable = True item.part.clean() item.part.save() diff --git a/InvenTree/part/fixtures/bom.yaml b/InvenTree/part/fixtures/bom.yaml index a9e1bed6f0..e879b8381f 100644 --- a/InvenTree/part/fixtures/bom.yaml +++ b/InvenTree/part/fixtures/bom.yaml @@ -30,4 +30,11 @@ fields: part: 100 sub_part: 50 - quantity: 3 \ No newline at end of file + quantity: 3 + +- model: part.bomitem + pk: 5 + fields: + part: 1 + sub_part: 5 + quantity: 3 diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index d7ad577081..8c43a623a0 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -4,6 +4,7 @@ Part database model definitions # -*- coding: utf-8 -*- from __future__ import unicode_literals +import decimal import os import logging @@ -1530,10 +1531,13 @@ class Part(MPTTModel): for item in self.get_bom_items().all().select_related('sub_part'): if item.sub_part.pk == self.pk: - print("Warning: Item contains itself in BOM") + logger.warning(f"WARNING: BomItem ID {item.pk} contains itself in BOM") continue - prices = item.sub_part.get_price_range(quantity * item.quantity, internal=internal, purchase=purchase) + q = decimal.Decimal(quantity) + i = decimal.Decimal(item.quantity) + + prices = item.sub_part.get_price_range(q * i, internal=internal, purchase=purchase) if prices is None: continue @@ -2329,6 +2333,23 @@ class BomItem(models.Model): def get_api_url(): return reverse('api-bom-list') + def get_stock_filter(self): + """ + Return a queryset filter for selecting StockItems which match this BomItem + + - If allow_variants is True, allow all part variants + + """ + + # Target part + part = self.sub_part + + if self.allow_variants: + variants = part.get_descendants(include_self=True) + return Q(part__in=[v.pk for v in variants]) + else: + return Q(part=part) + def save(self, *args, **kwargs): self.clean() diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 660b573e33..ac9d6bdf45 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -277,7 +277,7 @@ class PartAPITest(InvenTreeAPITestCase): """ There should be 4 BomItem objects in the database """ url = reverse('api-bom-list') response = self.client.get(url, format='json') - self.assertEqual(len(response.data), 4) + self.assertEqual(len(response.data), 5) def test_get_bom_detail(self): # Get the detail for a single BomItem diff --git a/InvenTree/part/test_bom_item.py b/InvenTree/part/test_bom_item.py index 66897b28fc..be9740d128 100644 --- a/InvenTree/part/test_bom_item.py +++ b/InvenTree/part/test_bom_item.py @@ -120,7 +120,13 @@ class BomItemTest(TestCase): def test_pricing(self): self.bob.get_price(1) - self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(84.5), Decimal(89.5))) + self.assertEqual( + self.bob.get_bom_price_range(1, internal=True), + (Decimal(29.5), Decimal(89.5)) + ) # remove internal price for R_2K2_0805 self.r1.internal_price_breaks.delete() - self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(82.5), Decimal(87.5))) + self.assertEqual( + self.bob.get_bom_price_range(1, internal=True), + (Decimal(27.5), Decimal(87.5)) + ) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index eaa65dd763..27c2426d53 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -2,11 +2,18 @@ JSON API for the Stock app """ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from decimal import Decimal, InvalidOperation +from datetime import datetime, timedelta + +from django.utils.translation import ugettext_lazy as _ + from django.conf.urls import url, include from django.urls import reverse from django.http import JsonResponse from django.db.models import Q -from django.utils.translation import ugettext_lazy as _ from rest_framework import status from rest_framework.serializers import ValidationError @@ -22,7 +29,7 @@ from .models import StockItemTracking from .models import StockItemAttachment from .models import StockItemTestResult -from part.models import Part, PartCategory +from part.models import BomItem, Part, PartCategory from part.serializers import PartBriefSerializer from company.models import Company, SupplierPart @@ -45,10 +52,6 @@ from InvenTree.helpers import str2bool, isNull from InvenTree.api import AttachmentMixin from InvenTree.filters import InvenTreeOrderingFilter -from decimal import Decimal, InvalidOperation - -from datetime import datetime, timedelta - class StockCategoryTree(TreeSerializer): title = _('Stock') @@ -670,14 +673,14 @@ class StockList(generics.ListCreateAPIView): return queryset def filter_queryset(self, queryset): + """ + Custom filtering for the StockItem queryset + """ params = self.request.query_params queryset = super().filter_queryset(queryset) - # Perform basic filtering: - # Note: We do not let DRF filter here, it be slow AF - supplier_part = params.get('supplier_part', None) if supplier_part: @@ -818,7 +821,7 @@ class StockList(generics.ListCreateAPIView): if loc_id is not None: # Filter by 'null' location (i.e. top-level items) - if isNull(loc_id): + if isNull(loc_id) and not cascade: queryset = queryset.filter(location=None) else: try: @@ -843,6 +846,18 @@ class StockList(generics.ListCreateAPIView): except (ValueError, PartCategory.DoesNotExist): raise ValidationError({"category": "Invalid category id specified"}) + # Does the client wish to filter by BomItem + bom_item_id = params.get('bom_item', None) + + if bom_item_id is not None: + try: + bom_item = BomItem.objects.get(pk=bom_item_id) + + queryset = queryset.filter(bom_item.get_stock_filter()) + + except (ValueError, BomItem.DoesNotExist): + pass + # Filter by StockItem status status = params.get('status', None) diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index dbcdbecb5e..463c3c9ae2 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -4,7 +4,6 @@ /* globals buildStatusDisplay, constructForm, - getFieldByName, global_settings, imageHoverIcon, inventreeGet, @@ -20,6 +19,7 @@ */ /* exported + allocateStockToBuild, editBuildOrder, loadAllocationTable, loadBuildOrderAllocationTable, @@ -102,6 +102,7 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) { */ var buildId = buildInfo.pk; + var partId = buildInfo.part; var outputId = 'untracked'; @@ -120,11 +121,10 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) { var html = `
`; - // "Auto" allocation only works for untracked stock items - if (!output && lines > 0) { + if (lines > 0) { html += makeIconButton( - 'fa-magic icon-blue', 'button-output-auto', outputId, - '{% trans "Auto-allocate stock items to this output" %}', + 'fa-sign-in-alt icon-blue', 'button-output-auto', outputId, + '{% trans "Allocate stock items to this build output" %}', ); } @@ -136,7 +136,6 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) { ); } - if (output) { // Add a button to "complete" the particular build output @@ -163,11 +162,17 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) { // Add callbacks for the buttons $(panel).find(`#button-output-auto-${outputId}`).click(function() { + + var bom_items = $(panel).find(`#allocation-table-${outputId}`).bootstrapTable('getData'); + // Launch modal dialog to perform auto-allocation - launchModalForm(`/build/${buildId}/auto-allocate/`, + allocateStockToBuild( + buildId, + partId, + bom_items, { - data: { - }, + source_location: buildInfo.source_location, + output: outputId, success: reloadTable, } ); @@ -344,18 +349,26 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { function requiredQuantity(row) { // Return the requied quantity for a given row + var quantity = 0; + if (output) { // "Tracked" parts are calculated against individual build outputs - return row.quantity * output.quantity; + quantity = row.quantity * output.quantity; } else { // "Untracked" parts are specified against the build itself - return row.quantity * buildInfo.quantity; + quantity = row.quantity * buildInfo.quantity; } + + // Store the required quantity in the row data + row.required = quantity; + + return quantity; } function sumAllocations(row) { // Calculat total allocations for a given row if (!row.allocations) { + row.allocated = 0; return 0; } @@ -365,6 +378,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { quantity += item.quantity; }); + row.allocated = quantity; + return quantity; } @@ -377,52 +392,28 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { // Primary key of the 'sub_part' var pk = $(this).attr('pk'); - // Launch form to allocate new stock against this output - launchModalForm('{% url "build-item-create" %}', { - success: reloadTable, - data: { - part: pk, - build: buildId, - install_into: outputId, - }, - secondary: [ - { - field: 'stock_item', - label: '{% trans "New Stock Item" %}', - title: '{% trans "Create new Stock Item" %}', - url: '{% url "stock-item-create" %}', - data: { - part: pk, - }, - }, + // Extract BomItem information from this row + var row = $(table).bootstrapTable('getRowByUniqueId', pk); + + if (!row) { + console.log('WARNING: getRowByUniqueId returned null'); + return; + } + + allocateStockToBuild( + buildId, + partId, + [ + row, ], - callback: [ - { - field: 'stock_item', - action: function(value) { - inventreeGet( - `/api/stock/${value}/`, {}, - { - success: function(response) { - - // How many items are actually available for the given stock item? - var available = response.quantity - response.allocated; - - var field = getFieldByName('#modal-form', 'quantity'); - - // Allocation quantity initial value - var initial = field.attr('value'); - - if (available < initial) { - field.val(available); - } - } - } - ); - } - } - ] - }); + { + source_location: buildInfo.source_location, + success: function(data) { + $(table).bootstrapTable('refresh'); + }, + output: output == null ? null : output.pk, + } + ); }); // Callback for 'buy' button @@ -636,11 +627,9 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { text = `{% trans "Quantity" %}: ${row.quantity}`; } - {% if build.status == BuildStatus.COMPLETE %} - url = `/stock/item/${row.pk}/`; - {% else %} - url = `/stock/item/${row.stock_item}/`; - {% endif %} + var pk = row.stock_item || row.pk; + + url = `/stock/item/${pk}/`; return renderLink(text, url); } @@ -687,22 +676,31 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { // Assign button callbacks to the newly created allocation buttons subTable.find('.button-allocation-edit').click(function() { var pk = $(this).attr('pk'); - launchModalForm(`/build/item/${pk}/edit/`, { - success: reloadTable, + + constructForm(`/api/build/item/${pk}/`, { + fields: { + quantity: {}, + }, + title: '{% trans "Edit Allocation" %}', + onSuccess: reloadTable, }); }); subTable.find('.button-allocation-delete').click(function() { var pk = $(this).attr('pk'); - launchModalForm(`/build/item/${pk}/delete/`, { - success: reloadTable, + + constructForm(`/api/build/item/${pk}/`, { + method: 'DELETE', + title: '{% trans "Remove Allocation" %}', + onSuccess: reloadTable, }); }); }, columns: [ { - field: 'pk', - visible: false, + visible: true, + switchable: false, + checkbox: true, }, { field: 'sub_part_detail.full_name', @@ -824,6 +822,317 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { } + +/** + * Allocate stock items to a build + * + * arguments: + * - buildId: ID / PK value for the build + * - partId: ID / PK value for the part being built + * - bom_items: A list of BomItem objects to be allocated + * + * options: + * - output: ID / PK of the associated build output (or null for untracked items) + * - source_location: ID / PK of the top-level StockLocation to take parts from (or null) + */ +function allocateStockToBuild(build_id, part_id, bom_items, options={}) { + + // ID of the associated "build output" (or null) + var output_id = options.output || null; + + var source_location = options.source_location; + + function renderBomItemRow(bom_item, quantity) { + + var pk = bom_item.pk; + var sub_part = bom_item.sub_part_detail; + + var thumb = thumbnailImage(bom_item.sub_part_detail.thumbnail); + + var delete_button = `
`; + + delete_button += makeIconButton( + 'fa-times icon-red', + 'button-row-remove', + pk, + '{% trans "Remove row" %}', + ); + + delete_button += `
`; + + var quantity_input = constructField( + `items_quantity_${pk}`, + { + type: 'decimal', + min_value: 0, + value: quantity || 0, + title: '{% trans "Specify stock allocation quantity" %}', + required: true, + }, + { + hideLabels: true, + } + ); + + var allocated_display = makeProgressBar( + bom_item.allocated, + bom_item.required, + ); + + var stock_input = constructField( + `items_stock_item_${pk}`, + { + type: 'related field', + required: 'true', + }, + { + hideLabels: true, + } + ); + + // var stock_input = constructRelatedFieldInput(`items_stock_item_${pk}`); + + var html = ` + + + ${thumb} ${sub_part.full_name} + + + ${allocated_display} + + + ${stock_input} + + + ${quantity_input} + + + ${delete_button} + + + `; + + return html; + } + + var table_entries = ''; + + for (var idx = 0; idx < bom_items.length; idx++) { + var bom_item = bom_items[idx]; + + var required = bom_item.required || 0; + var allocated = bom_item.allocated || 0; + var remaining = required - allocated; + + if (remaining < 0) { + remaining = 0; + } + + table_entries += renderBomItemRow(bom_item, remaining); + } + + if (bom_items.length == 0) { + + showAlertDialog( + '{% trans "Select Parts" %}', + '{% trans "You must select at least one part to allocate" %}', + ); + + return; + } + + var html = ``; + + // Render a "take from" input + html += constructField( + 'take_from', + { + type: 'related field', + label: '{% trans "Source Location" %}', + help_text: '{% trans "Select source location (leave blank to take from all locations)" %}', + required: false, + }, + {}, + ); + + // Create table of parts + html += ` + + + + + + + + + + + + ${table_entries} + +
{% trans "Part" %}{% trans "Allocated" %}{% trans "Stock Item" %}{% trans "Quantity" %}
+ `; + + + constructForm(`/api/build/${build_id}/allocate/`, { + method: 'POST', + fields: {}, + preFormContent: html, + confirm: true, + confirmMessage: '{% trans "Confirm stock allocation" %}', + title: '{% trans "Allocate Stock Items to Build Order" %}', + afterRender: function(fields, options) { + + var take_from_field = { + name: 'take_from', + model: 'stocklocation', + api_url: '{% url "api-location-list" %}', + required: false, + type: 'related field', + value: source_location, + noResults: function(query) { + return '{% trans "No matching stock locations" %}'; + }, + }; + + // Initialize "take from" field + initializeRelatedField( + take_from_field, + null, + options, + ); + + // Initialize stock item fields + bom_items.forEach(function(bom_item) { + initializeRelatedField( + { + name: `items_stock_item_${bom_item.pk}`, + api_url: '{% url "api-stock-list" %}', + filters: { + bom_item: bom_item.pk, + in_stock: true, + part_detail: false, + location_detail: true, + }, + model: 'stockitem', + required: true, + render_part_detail: false, + render_location_detail: true, + auto_fill: true, + adjustFilters: function(filters) { + // Restrict query to the selected location + var location = getFormFieldValue( + 'take_from', + {}, + { + modal: options.modal, + } + ); + + filters.location = location; + filters.cascade = true; + + return filters; + }, + noResults: function(query) { + return '{% trans "No matching stock items" %}'; + } + }, + null, + options, + ); + }); + + // Add callback to "clear" button for take_from field + addClearCallback( + 'take_from', + take_from_field, + options, + ); + + // Add button callbacks + $(options.modal).find('.button-row-remove').click(function() { + var pk = $(this).attr('pk'); + + $(options.modal).find(`#allocation_row_${pk}`).remove(); + }); + }, + onSubmit: function(fields, opts) { + + // Extract elements from the form + var data = { + items: [] + }; + + var item_pk_values = []; + + bom_items.forEach(function(item) { + + var quantity = getFormFieldValue( + `items_quantity_${item.pk}`, + {}, + { + modal: opts.modal, + }, + ); + + var stock_item = getFormFieldValue( + `items_stock_item_${item.pk}`, + {}, + { + modal: opts.modal, + } + ); + + if (quantity != null) { + data.items.push({ + bom_item: item.pk, + stock_item: stock_item, + quantity: quantity, + output: output_id, + }); + + item_pk_values.push(item.pk); + } + }); + + // Provide nested values + opts.nested = { + 'items': item_pk_values + }; + + inventreePut( + opts.url, + data, + { + method: 'POST', + success: function(response) { + // Hide the modal + $(opts.modal).modal('hide'); + + if (options.success) { + options.success(response); + } + }, + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, opts); + break; + default: + $(opts.modal).modal('hide'); + showApiError(xhr); + break; + } + } + } + ); + }, + }); +} + + + function loadBuildTable(table, options) { // Display a table of Build objects diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 0e815f8c6d..bffb42403b 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -728,10 +728,17 @@ function updateFieldValues(fields, options) { } } - +/* + * Update the value of a named field + */ function updateFieldValue(name, value, field, options) { var el = $(options.modal).find(`#id_${name}`); + if (!el) { + console.log(`WARNING: updateFieldValue could not find field '${name}'`); + return; + } + switch (field.type) { case 'boolean': el.prop('checked', value); @@ -864,6 +871,78 @@ function clearFormErrors(options) { $(options.modal).find('#non-field-errors').html(''); } +/* + * Display form error messages as returned from the server, + * specifically for errors returned in an array. + * + * We need to know the unique ID of each item in the array, + * and the array length must equal the length of the array returned from the server + * + * arguments: + * - response: The JSON error response from the server + * - parent: The name of the parent field e.g. "items" + * - options: The global options struct + * + * options: + * - nested: A map of nested ID values for the "parent" field + * e.g. + * { + * "items": [ + * 1, + * 2, + * 12 + * ] + * } + * + */ + +function handleNestedErrors(errors, field_name, options) { + + var error_list = errors[field_name]; + + // Ignore null or empty list + if (!error_list) { + return; + } + + var nest_list = nest_list = options['nested'][field_name]; + + // Nest list must be provided! + if (!nest_list) { + console.log(`WARNING: handleNestedErrors missing nesting options for field '${fieldName}'`); + return; + } + + for (var idx = 0; idx < error_list.length; idx++) { + + var error_item = error_list[idx]; + + if (idx >= nest_list.length) { + console.log(`WARNING: handleNestedErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`); + break; + } + + // Extract the particular ID of the nested item + var nest_id = nest_list[idx]; + + // Here, error_item is a map of field names to error messages + for (sub_field_name in error_item) { + var errors = error_item[sub_field_name]; + + // Find the target (nested) field + var target = `${field_name}_${sub_field_name}_${nest_id}`; + + for (var ii = errors.length-1; ii >= 0; ii--) { + + var error_text = errors[ii]; + + addFieldErrorMessage(target, error_text, ii, options); + } + } + } +} + + /* * Display form error messages as returned from the server. @@ -913,28 +992,30 @@ function handleFormErrors(errors, fields, options) { for (var field_name in errors) { - // Add the 'has-error' class - $(options.modal).find(`#div_id_${field_name}`).addClass('has-error'); + if (field_name in fields) { - var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`); + var field = fields[field_name]; - var field_errors = errors[field_name]; + if ((field.type == 'field') && ('child' in field)) { + // This is a "nested" field + handleNestedErrors(errors, field_name, options); + } else { + // This is a "simple" field - if (field_errors && !first_error_field && isFieldVisible(field_name, options)) { - first_error_field = field_name; - } + var field_errors = errors[field_name]; - // Add an entry for each returned error message - for (var ii = field_errors.length-1; ii >= 0; ii--) { + if (field_errors && !first_error_field && isFieldVisible(field_name, options)) { + first_error_field = field_name; + } - var error_text = field_errors[ii]; + // Add an entry for each returned error message + for (var ii = field_errors.length-1; ii >= 0; ii--) { - var error_html = ` - - ${error_text} - `; + var error_text = field_errors[ii]; - field_dom.append(error_html); + addFieldErrorMessage(field_name, error_text, ii, options); + } + } } } @@ -952,6 +1033,30 @@ function handleFormErrors(errors, fields, options) { } +/* + * Add a rendered error message to the provided field + */ +function addFieldErrorMessage(field_name, error_text, error_idx, options) { + + // Add the 'has-error' class + $(options.modal).find(`#div_id_${field_name}`).addClass('has-error'); + + var field_dom = $(options.modal).find(`#errors-${field_name}`); + + if (field_dom) { + + var error_html = ` + + ${error_text} + `; + + field_dom.append(error_html); + } else { + console.log(`WARNING: addFieldErrorMessage could not locate field '${field_name}`); + } +} + + function isFieldVisible(field, options) { return $(options.modal).find(`#div_id_${field}`).is(':visible'); @@ -1007,7 +1112,14 @@ function addClearCallbacks(fields, options) { function addClearCallback(name, field, options) { - $(options.modal).find(`#clear_${name}`).click(function() { + var el = $(options.modal).find(`#clear_${name}`); + + if (!el) { + console.log(`WARNING: addClearCallback could not find field '${name}'`); + return; + } + + el.click(function() { updateFieldValue(name, null, field, options); }); } @@ -1168,7 +1280,7 @@ function addSecondaryModal(field, fields, options) { /* - * Initializea single related-field + * Initialize a single related-field * * argument: * - modal: DOM identifier for the modal window @@ -1182,7 +1294,7 @@ function initializeRelatedField(field, fields, options) { if (!field.api_url) { // TODO: Provide manual api_url option? - console.log(`Related field '${name}' missing 'api_url' parameter.`); + console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`); return; } @@ -1203,6 +1315,15 @@ function initializeRelatedField(field, fields, options) { placeholder: '', dropdownParent: $(options.modal), dropdownAutoWidth: false, + language: { + noResults: function(query) { + if (field.noResults) { + return field.noResults(query); + } else { + return '{% trans "No results found" %}'; + } + } + }, ajax: { url: field.api_url, dataType: 'json', @@ -1225,6 +1346,11 @@ function initializeRelatedField(field, fields, options) { query.search = params.term; query.offset = offset; query.limit = pageSize; + + // Allow custom run-time filter augmentation + if ('adjustFilters' in field) { + query = field.adjustFilters(query); + } return query; }, @@ -1319,6 +1445,7 @@ function initializeRelatedField(field, fields, options) { // If a 'value' is already defined, grab the model info from the server if (field.value) { + var pk = field.value; var url = `${field.api_url}/${pk}/`.replace('//', '/'); @@ -1327,6 +1454,24 @@ function initializeRelatedField(field, fields, options) { setRelatedFieldData(name, data, options); } }); + } else if (field.auto_fill) { + // Attempt to auto-fill the field + + var filters = field.filters || {}; + + // Enforce pagination, limit to a single return (for fast query) + filters.limit = 1; + filters.offset = 0; + + inventreeGet(field.api_url, field.filters || {}, { + success: function(data) { + + // Only a single result is available, given the provided filters + if (data.count == 1) { + setRelatedFieldData(name, data.results[0], options); + } + } + }); } } @@ -1884,7 +2029,7 @@ function constructChoiceInput(name, parameters) { */ function constructRelatedFieldInput(name) { - var html = ``; + var html = ``; // Don't load any options - they will be filled via an AJAX request diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index 6e3f7f0c95..164452952d 100644 --- a/InvenTree/templates/js/translated/helpers.js +++ b/InvenTree/templates/js/translated/helpers.js @@ -65,7 +65,7 @@ function imageHoverIcon(url) { function thumbnailImage(url) { if (!url) { - url = '/static/img/blank_img.png'; + url = blankImage(); } // TODO: Support insertion of custom classes diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index 389d5a650f..3136ebee29 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -37,7 +37,7 @@ function renderCompany(name, data, parameters, options) { html += `${data.name} - ${data.description}`; - html += `{% trans "Company ID" %}: ${data.pk}`; + html += `{% trans "Company ID" %}: ${data.pk}`; return html; } @@ -47,22 +47,59 @@ function renderCompany(name, data, parameters, options) { // eslint-disable-next-line no-unused-vars function renderStockItem(name, data, parameters, options) { - var image = data.part_detail.thumbnail || data.part_detail.image || blankImage(); - - var html = ``; - - html += ` ${data.part_detail.full_name || data.part_detail.name}`; - - if (data.serial && data.quantity == 1) { - html += ` - {% trans "Serial Number" %}: ${data.serial}`; - } else { - html += ` - {% trans "Quantity" %}: ${data.quantity}`; + var image = blankImage(); + + if (data.part_detail) { + image = data.part_detail.thumbnail || data.part_detail.image || blankImage(); } - if (data.part_detail.description) { + var html = ''; + + var render_part_detail = true; + + if ('render_part_detail' in parameters) { + render_part_detail = parameters['render_part_detail']; + } + + if (render_part_detail) { + html += ``; + html += ` ${data.part_detail.full_name || data.part_detail.name}`; + } + + html += ''; + + if (data.serial && data.quantity == 1) { + html += `{% trans "Serial Number" %}: ${data.serial}`; + } else { + html += `{% trans "Quantity" %}: ${data.quantity}`; + } + + html += ''; + + if (render_part_detail && data.part_detail.description) { html += `

${data.part_detail.description}

`; } + var render_stock_id = true; + + if ('render_stock_id' in parameters) { + render_stock_id = parameters['render_stock_id']; + } + + if (render_stock_id) { + html += `{% trans "Stock ID" %}: ${data.pk}`; + } + + var render_location_detail = false; + + if ('render_location_detail' in parameters) { + render_location_detail = parameters['render_location_detail']; + } + + if (render_location_detail && data.location_detail) { + html += ` - ${data.location_detail.name}`; + } + return html; } @@ -79,7 +116,7 @@ function renderStockLocation(name, data, parameters, options) { html += ` - ${data.description}`; } - html += `{% trans "Location ID" %}: ${data.pk}`; + html += `{% trans "Location ID" %}: ${data.pk}`; return html; } @@ -96,7 +133,7 @@ function renderBuild(name, data, parameters, options) { var html = select2Thumbnail(image); html += `${data.reference} - ${data.quantity} x ${data.part_detail.full_name}`; - html += `{% trans "Build ID" %}: ${data.pk}`; + html += `{% trans "Build ID" %}: ${data.pk}`; html += `

${data.title}

`; @@ -116,7 +153,7 @@ function renderPart(name, data, parameters, options) { html += ` - ${data.description}`; } - html += `{% trans "Part ID" %}: ${data.pk}`; + html += `{% trans "Part ID" %}: ${data.pk}`; return html; } @@ -168,7 +205,7 @@ function renderPartCategory(name, data, parameters, options) { html += ` - ${data.description}`; } - html += `{% trans "Category ID" %}: ${data.pk}`; + html += `{% trans "Category ID" %}: ${data.pk}`; return html; } @@ -205,7 +242,7 @@ function renderManufacturerPart(name, data, parameters, options) { html += ` ${data.manufacturer_detail.name} - ${data.MPN}`; html += ` - ${data.part_detail.full_name}`; - html += `{% trans "Manufacturer Part ID" %}: ${data.pk}`; + html += `{% trans "Manufacturer Part ID" %}: ${data.pk}`; return html; } @@ -234,7 +271,7 @@ function renderSupplierPart(name, data, parameters, options) { html += ` ${data.supplier_detail.name} - ${data.SKU}`; html += ` - ${data.part_detail.full_name}`; - html += `{% trans "Supplier Part ID" %}: ${data.pk}`; + html += `{% trans "Supplier Part ID" %}: ${data.pk}`; return html; diff --git a/tasks.py b/tasks.py index 1abbf23bc6..59fa83e56b 100644 --- a/tasks.py +++ b/tasks.py @@ -127,13 +127,20 @@ def worker(c): @task -def rebuild(c): +def rebuild_models(c): """ Rebuild database models with MPTT structures """ - manage(c, "rebuild_models") + manage(c, "rebuild_models", pty=True) +@task +def rebuild_thumbnails(c): + """ + Rebuild missing image thumbnails + """ + + manage(c, "rebuild_thumbnails", pty=True) @task def clean_settings(c): @@ -143,7 +150,7 @@ def clean_settings(c): manage(c, "clean_settings") -@task(post=[rebuild]) +@task(post=[rebuild_models, rebuild_thumbnails]) def migrate(c): """ Performs database migrations. @@ -341,7 +348,7 @@ def export_records(c, filename='data.json'): print("Data export completed") -@task(help={'filename': 'Input filename'}, post=[rebuild]) +@task(help={'filename': 'Input filename'}, post=[rebuild_models, rebuild_thumbnails]) def import_records(c, filename='data.json'): """ Import database records from a file @@ -399,7 +406,7 @@ def delete_data(c, force=False): manage(c, 'flush') -@task(post=[rebuild]) +@task(post=[rebuild_models, rebuild_thumbnails]) def import_fixtures(c): """ Import fixture data into the database.