diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index eca502425a..d71f5d1f08 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -933,7 +933,8 @@ input[type="submit"] { .panel-inventree { padding: 10px; - box-shadow: 1px 1px #DDD; + box-shadow: 2px 2px #DDD; + border-color: #ccc; } .panel-hidden { @@ -1074,6 +1075,14 @@ input[type='number']{ margin-top: 0.5rem; } +.product-card { + width: 20%; + padding: 5px; + min-height: 25px; +} + .product-card-panel{ height: 100%; + border: 1px solid #ccc; + box-shadow: 2px 2px #DDD; } diff --git a/InvenTree/InvenTree/static/script/inventree/delay.js b/InvenTree/InvenTree/static/script/inventree/delay.js deleted file mode 100644 index 9070d0c5b9..0000000000 --- a/InvenTree/InvenTree/static/script/inventree/delay.js +++ /dev/null @@ -1,12 +0,0 @@ -var msDelay = 0; - -var delay = (function(){ - return function(callback, ms){ - clearTimeout(msDelay); - msDelay = setTimeout(callback, ms); - }; -})(); - -function cancelTimer(){ - clearTimeout(msDelay); -} \ No newline at end of file diff --git a/InvenTree/InvenTree/static/script/inventree/sidenav.js b/InvenTree/InvenTree/static/script/inventree/sidenav.js deleted file mode 100644 index eca19076f2..0000000000 --- a/InvenTree/InvenTree/static/script/inventree/sidenav.js +++ /dev/null @@ -1,249 +0,0 @@ -function loadTree(url, tree, options={}) { - /* Load the side-nav tree view - - Args: - url: URL to request tree data - tree: html ref to treeview - options: - data: data object to pass to the AJAX request - selected: ID of currently selected item - name: name of the tree - */ - - var data = {}; - - if (options.data) { - data = options.data; - } - - var key = "inventree-sidenav-items-"; - - if (options.name) { - key += options.name; - } - - $.ajax({ - url: url, - type: 'get', - dataType: 'json', - data: data, - success: function (response) { - if (response.tree) { - $(tree).treeview({ - data: response.tree, - enableLinks: true, - showTags: true, - }); - - if (localStorage.getItem(key)) { - var saved_exp = localStorage.getItem(key).split(","); - - // Automatically expand the desired notes - for (var q = 0; q < saved_exp.length; q++) { - $(tree).treeview('expandNode', parseInt(saved_exp[q])); - } - } - - // Setup a callback whenever a node is toggled - $(tree).on('nodeExpanded nodeCollapsed', function(event, data) { - - // Record the entire list of expanded items - var expanded = $(tree).treeview('getExpanded'); - - var exp = []; - - for (var i = 0; i < expanded.length; i++) { - exp.push(expanded[i].nodeId); - } - - // Save the expanded nodes - localStorage.setItem(key, exp); - }); - } - }, - error: function (xhr, ajaxOptions, thrownError) { - //TODO - } - }); -} - - -/** - * Initialize navigation tree display - */ -function initNavTree(options) { - - var resize = true; - - if ('resize' in options) { - resize = options.resize; - } - - var label = options.label || 'nav'; - - var stateLabel = `${label}-tree-state`; - var widthLabel = `${label}-tree-width`; - - var treeId = options.treeId || '#sidenav-left'; - var toggleId = options.toggleId; - - // Initially hide the tree - $(treeId).animate({ - width: '0px', - }, 0, function() { - - if (resize) { - $(treeId).resizable({ - minWidth: '0px', - maxWidth: '500px', - handles: 'e, se', - grid: [5, 5], - stop: function(event, ui) { - var width = Math.round(ui.element.width()); - - if (width < 75) { - $(treeId).animate({ - width: '0px' - }, 50); - - localStorage.setItem(stateLabel, 'closed'); - } else { - localStorage.setItem(stateLabel, 'open'); - localStorage.setItem(widthLabel, `${width}px`); - } - } - }); - } - - var state = localStorage.getItem(stateLabel); - var width = localStorage.getItem(widthLabel) || '300px'; - - if (state && state == 'open') { - - $(treeId).animate({ - width: width, - }, 50); - } - }); - - // Register callback for 'toggle' button - if (toggleId) { - - $(toggleId).click(function() { - - var state = localStorage.getItem(stateLabel) || 'closed'; - var width = localStorage.getItem(widthLabel) || '300px'; - - if (state == 'open') { - $(treeId).animate({ - width: '0px' - }, 50); - - localStorage.setItem(stateLabel, 'closed'); - } else { - $(treeId).animate({ - width: width, - }, 50); - - localStorage.setItem(stateLabel, 'open'); - } - }); - } -} - - -/** - * Handle left-hand icon menubar display - */ -function enableNavbar(options) { - - var resize = true; - - if ('resize' in options) { - resize = options.resize; - } - - var label = options.label || 'nav'; - - label = `navbar-${label}`; - - var stateLabel = `${label}-state`; - var widthLabel = `${label}-width`; - - var navId = options.navId || '#sidenav-right'; - - var toggleId = options.toggleId; - - // Extract the saved width for this element - $(navId).animate({ - width: '45px', - 'min-width': '45px', - display: 'block', - }, 50, function() { - - // Make the navbar resizable - if (resize) { - $(navId).resizable({ - minWidth: options.minWidth || '100px', - maxWidth: options.maxWidth || '500px', - handles: 'e, se', - grid: [5, 5], - stop: function(event, ui) { - // Record the new width - var width = Math.round(ui.element.width()); - - // Reasonably narrow? Just close it! - if (width <= 75) { - $(navId).animate({ - width: '45px' - }, 50); - - localStorage.setItem(stateLabel, 'closed'); - } else { - localStorage.setItem(widthLabel, `${width}px`); - localStorage.setItem(stateLabel, 'open'); - } - } - }); - } - - var state = localStorage.getItem(stateLabel); - - var width = localStorage.getItem(widthLabel) || '250px'; - - if (state && state == 'open') { - - $(navId).animate({ - width: width - }, 100); - } - - }); - - // Register callback for 'toggle' button - if (toggleId) { - - $(toggleId).click(function() { - - var state = localStorage.getItem(stateLabel) || 'closed'; - var width = localStorage.getItem(widthLabel) || '250px'; - - if (state == 'open') { - $(navId).animate({ - width: '45px', - minWidth: '45px', - }, 50); - - localStorage.setItem(stateLabel, 'closed'); - - } else { - - $(navId).animate({ - 'width': width - }, 50); - - localStorage.setItem(stateLabel, 'open'); - } - }); - } -} \ No newline at end of file diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 5bbae8565e..48539713f2 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,11 +12,14 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 15 +INVENTREE_API_VERSION = 16 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v16 -> 2021-10-17 + - Adds API endpoint for completing build order outputs + v15 -> 2021-10-06 - Adds detail endpoint for SalesOrderAllocation model - Allows use of the API forms interface for adjusting SalesOrderAllocation objects diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index cf4d44a03e..819ffed1b6 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -5,12 +5,9 @@ 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, generics -from rest_framework.serializers import ValidationError from django_filters.rest_framework import DjangoFilterBackend from django_filters import rest_framework as rest_filters @@ -21,7 +18,7 @@ from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.status_codes import BuildStatus from .models import Build, BuildItem, BuildOrderAttachment -from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer +from .serializers import BuildAttachmentSerializer, BuildCompleteSerializer, BuildSerializer, BuildItemSerializer from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer @@ -201,30 +198,43 @@ class BuildUnallocate(generics.CreateAPIView): queryset = Build.objects.none() serializer_class = BuildUnallocationSerializer - - 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 (ValueError, Build.DoesNotExist): - raise ValidationError(_("Matching build order does not exist")) - - return build - + def get_serializer_context(self): ctx = super().get_serializer_context() - ctx['build'] = self.get_build() + + try: + ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + ctx['request'] = self.request return ctx +class BuildComplete(generics.CreateAPIView): + """ + API endpoint for completing build outputs + """ + + queryset = Build.objects.none() + + serializer_class = BuildCompleteSerializer + + def get_serializer_context(self): + ctx = super().get_serializer_context() + + ctx['request'] = self.request + + try: + ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + return ctx + + class BuildAllocate(generics.CreateAPIView): """ API endpoint to allocate stock items to a build order @@ -241,20 +251,6 @@ class BuildAllocate(generics.CreateAPIView): 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 @@ -262,7 +258,11 @@ class BuildAllocate(generics.CreateAPIView): context = super().get_serializer_context() - context['build'] = self.get_build() + try: + context['build'] = Build.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + context['request'] = self.request return context @@ -390,6 +390,7 @@ build_api_urls = [ # Build Detail url(r'^(?P\d+)/', include([ url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), + url(r'^complete/', BuildComplete.as_view(), name='api-build-complete'), url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'), ])), diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index bc7bdd50f5..19bf3566dc 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -10,63 +10,9 @@ from django.utils.translation import ugettext_lazy as _ from django import forms 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 -from stock.models import StockLocation, StockItem - - -class EditBuildForm(HelperForm): - """ Form for editing a Build object. - """ - - field_prefix = { - 'reference': 'BO', - 'link': 'fa-link', - 'batch': 'fa-layer-group', - 'serial-numbers': 'fa-hashtag', - 'location': 'fa-map-marker-alt', - 'target_date': 'fa-calendar-alt', - } - - field_placeholder = { - 'reference': _('Build Order reference'), - 'target_date': _('Order target date'), - } - - target_date = DatePickerFormField( - label=_('Target Date'), - help_text=_('Target date for build completion. Build will be overdue after this date.') - ) - - quantity = RoundingDecimalFormField( - max_digits=10, decimal_places=5, - label=_('Quantity'), - help_text=_('Number of items to build') - ) - - class Meta: - model = Build - fields = [ - 'reference', - 'title', - 'part', - 'quantity', - 'batch', - 'target_date', - 'take_from', - 'destination', - 'parent', - 'sales_order', - 'link', - 'issued_by', - 'responsible', - ] - class BuildOutputCreateForm(HelperForm): """ @@ -155,59 +101,6 @@ class CompleteBuildForm(HelperForm): ] -class CompleteBuildOutputForm(HelperForm): - """ - Form for completing a single build output - """ - - field_prefix = { - 'serial_numbers': 'fa-hashtag', - } - - field_placeholder = { - } - - location = forms.ModelChoiceField( - queryset=StockLocation.objects.all(), - label=_('Location'), - 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'), - help_text=_("Confirm completion with incomplete stock allocation") - ) - - confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm build completion')) - - output = forms.ModelChoiceField( - queryset=StockItem.objects.all(), # Queryset is narrowed in the view - widget=forms.HiddenInput(), - ) - - class Meta: - model = Build - 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 c477794e8c..403b3a9430 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -724,7 +724,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): items.all().delete() @transaction.atomic - def completeBuildOutput(self, output, user, **kwargs): + def complete_build_output(self, output, user, **kwargs): """ Complete a particular build output @@ -741,10 +741,6 @@ class Build(MPTTModel, ReferenceIndexingMixin): allocated_items = output.items_to_install.all() for build_item in allocated_items: - - # TODO: This is VERY SLOW as each deletion from the database takes ~1 second to complete - # TODO: Use the background worker process to handle this task! - # Complete the allocation of stock for that item build_item.complete_allocation(user) @@ -770,6 +766,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): # Increase the completed quantity for this build self.completed += output.quantity + self.save() def requiredQuantity(self, part, output): diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 547f565905..a18f58fb76 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -18,9 +18,10 @@ from rest_framework.serializers import ValidationError from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief +from InvenTree.status_codes import StockStatus import InvenTree.helpers -from stock.models import StockItem +from stock.models import StockItem, StockLocation from stock.serializers import StockItemSerializerBrief, LocationSerializer from part.models import BomItem @@ -120,6 +121,124 @@ class BuildSerializer(InvenTreeModelSerializer): ] +class BuildOutputSerializer(serializers.Serializer): + """ + Serializer for a "BuildOutput" + + Note that a "BuildOutput" is really just a StockItem which is "in production"! + """ + + output = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.all(), + many=False, + allow_null=False, + required=True, + label=_('Build Output'), + ) + + def validate_output(self, output): + + build = self.context['build'] + + # The stock item must point to the build + if output.build != build: + raise ValidationError(_("Build output does not match the parent build")) + + # The part must match! + if output.part != build.part: + raise ValidationError(_("Output part does not match BuildOrder part")) + + # The build output must be "in production" + if not output.is_building: + raise ValidationError(_("This build output has already been completed")) + + # The build output must have all tracked parts allocated + if not build.isFullyAllocated(output): + raise ValidationError(_("This build output is not fully allocated")) + + return output + + class Meta: + fields = [ + 'output', + ] + + +class BuildCompleteSerializer(serializers.Serializer): + """ + DRF serializer for completing one or more build outputs + """ + + class Meta: + fields = [ + 'outputs', + 'location', + 'status', + 'notes', + ] + + outputs = BuildOutputSerializer( + many=True, + required=True, + ) + + location = serializers.PrimaryKeyRelatedField( + queryset=StockLocation.objects.all(), + required=True, + many=False, + label=_("Location"), + help_text=_("Location for completed build outputs"), + ) + + status = serializers.ChoiceField( + choices=list(StockStatus.items()), + default=StockStatus.OK, + label=_("Status"), + ) + + notes = serializers.CharField( + label=_("Notes"), + required=False, + allow_blank=True, + ) + + def validate(self, data): + + super().validate(data) + + outputs = data.get('outputs', []) + + if len(outputs) == 0: + raise ValidationError(_("A list of build outputs must be provided")) + + return data + + def save(self): + """ + "save" the serializer to complete the build outputs + """ + + build = self.context['build'] + request = self.context['request'] + + data = self.validated_data + + outputs = data.get('outputs', []) + + # Mark the specified build outputs as "complete" + with transaction.atomic(): + for item in outputs: + + output = item['output'] + + build.complete_build_output( + output, + request.user, + status=data['status'], + notes=data.get('notes', '') + ) + + class BuildUnallocationSerializer(serializers.Serializer): """ DRF serializer for unallocating stock from a BuildOrder @@ -190,6 +309,8 @@ class BuildAllocationItemSerializer(serializers.Serializer): def validate_bom_item(self, bom_item): + # TODO: Fix this validation - allow for variants and substitutes! + build = self.context['build'] # BomItem must point to the same 'part' as the parent build diff --git a/InvenTree/build/templates/build/allocation_card.html b/InvenTree/build/templates/build/allocation_card.html deleted file mode 100644 index 3ce4a52aeb..0000000000 --- a/InvenTree/build/templates/build/allocation_card.html +++ /dev/null @@ -1,51 +0,0 @@ -{% load i18n %} -{% load inventree_extras %} - -{% define item.pk as pk %} - -
- -
-
-
-
-
-
\ No newline at end of file diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index e3119e6fdb..1731e00403 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -91,16 +91,11 @@ src="{% static 'img/blank_image.png' %}" {% if roles.build.change %} - {% if build.active %} - - {% endif %}
+ {% if build.active %} + + {% endif %} {% endif %} {% endblock %} @@ -153,8 +153,8 @@ src="{% static 'img/blank_image.png' %}" {% endif %} - - {% trans "Progress" %} + + {% trans "Completed" %} {{ build.completed }} / {{ build.quantity }} {% if build.parent %} diff --git a/InvenTree/build/templates/build/complete_output.html b/InvenTree/build/templates/build/complete_output.html deleted file mode 100644 index d03885774f..0000000000 --- a/InvenTree/build/templates/build/complete_output.html +++ /dev/null @@ -1,53 +0,0 @@ -{% extends "modal_form.html" %} -{% load inventree_extras %} -{% load i18n %} - -{% block pre_form_content %} - -{% if not build.has_tracked_bom_items %} -{% elif fully_allocated %} -
- {% trans "Stock allocation is complete for this output" %} -
-{% else %} -
-

{% trans "Stock allocation is incomplete" %}

- -
-
- -
-
-
    - {% for part in unallocated_parts %} -
  • - {% include "hover_image.html" with image=part.image %} {{ part }} -
  • - {% endfor %} -
-
-
-
-
-
-{% endif %} - -
-
- {% trans "The following items will be created" %} -
-
- {% include "hover_image.html" with image=build.part.image %} - {% if output.serialized %} - {{ output.part.full_name }} - {% trans "Serial Number" %} {{ output.serial }} - {% else %} - {% decimal output.quantity %} x {{ output.part.full_name }} - {% endif %} -
-
- -{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index cfba2046e3..f87eec90a0 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -63,10 +63,17 @@ {% build_status_label build.status %} - - {% trans "Progress" %} + + {% trans "Completed" %} {{ build.completed }} / {{ build.quantity }} + {% if build.active and build.has_untracked_bom_items %} + + + {% trans "Allocated Parts" %} + + + {% endif %} {% if build.batch %} @@ -213,35 +220,35 @@
- {% if not build.is_complete %}

{% trans "Incomplete Build Outputs" %}

-
- {% if build.active %} - - {% endif %} +
+
+ {% if build.active %} +
+ + + +
+ {% endif %} +
- - {% if build.incomplete_outputs %} -
- {% for item in build.incomplete_outputs %} - {% include "build/allocation_card.html" with item=item tracked_items=build.has_tracked_bom_items %} - {% endfor %} -
- {% else %} -
- {% trans "Create a new build output" %}
- {% trans "No incomplete build outputs remain." %}
- {% trans "Create a new build output using the button above" %} -
- {% endif %} +
- {% endif %} +
+

{% trans "Completed Build Outputs" %} @@ -313,26 +320,75 @@ loadStockTable($("#build-stock-table"), { url: "{% url 'api-stock-list' %}", }); -var buildInfo = { - pk: {{ build.pk }}, - 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 %} -// Get the build output as a javascript object -inventreeGet('{% url 'api-stock-detail' item.pk %}', {}, +// Get the list of BOM items required for this build +inventreeGet( + '{% url "api-bom-list" %}', + { + part: {{ build.part.pk }}, + sub_part_detail: true, + }, { success: function(response) { - loadBuildOutputAllocationTable(buildInfo, response); + + var build_info = { + pk: {{ build.pk }}, + part: {{ build.part.pk }}, + quantity: {{ build.quantity }}, + bom_items: response, + {% if build.take_from %} + source_location: {{ build.take_from.pk }}, + {% endif %} + {% if build.has_tracked_bom_items %} + tracked_parts: true, + {% else %} + tracked_parts: false, + {% endif %} + }; + + {% if build.active %} + loadBuildOutputTable(build_info); + linkButtonsToSelection( + '#build-output-table', + [ + '#output-options', + '#multi-output-complete', + ] + ); + + $('#multi-output-complete').click(function() { + var outputs = $('#build-output-table').bootstrapTable('getSelections'); + + completeBuildOutputs( + build_info.pk, + outputs, + { + success: function() { + // Reload the "in progress" table + $('#build-output-table').bootstrapTable('refresh'); + + // Reload the "completed" table + $('#build-stock-table').bootstrapTable('refresh'); + } + } + ); + }); + + {% endif %} + + {% if build.active and build.has_untracked_bom_items %} + // Load allocation table for un-tracked parts + loadBuildOutputAllocationTable( + build_info, + null, + { + search: true, + } + ); + {% endif %} } } ); -{% endfor %} loadBuildTable($('#sub-build-table'), { url: '{% url "api-build-list" %}', @@ -342,6 +398,7 @@ loadBuildTable($('#sub-build-table'), { } }); + enableDragAndDrop( '#attachment-dropzone', '{% url "api-build-attachment-list" %}', @@ -416,11 +473,6 @@ $('#edit-notes').click(function() { }); }); -{% if build.has_untracked_bom_items %} -// Load allocation table for un-tracked parts -loadBuildOutputAllocationTable(buildInfo, null); -{% endif %} - function reloadTable() { $('#allocation-table-untracked').bootstrapTable('refresh'); } @@ -471,6 +523,10 @@ $('#allocate-selected-items').click(function() { var bom_items = $("#allocation-table-untracked").bootstrapTable("getSelections"); + if (bom_items.length == 0) { + bom_items = $("#allocation-table-untracked").bootstrapTable('getData'); + } + allocateStockToBuild( {{ build.pk }}, {{ build.part.pk }}, diff --git a/InvenTree/build/templates/build/edit_build_item.html b/InvenTree/build/templates/build/edit_build_item.html deleted file mode 100644 index 99cad71ba2..0000000000 --- a/InvenTree/build/templates/build/edit_build_item.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} -
-

- {% trans "Alter the quantity of stock allocated to the build output" %} -

-
-{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/navbar.html b/InvenTree/build/templates/build/navbar.html index e4c4fe4e50..9b159503dc 100644 --- a/InvenTree/build/templates/build/navbar.html +++ b/InvenTree/build/templates/build/navbar.html @@ -19,16 +19,25 @@ {% if build.active %}
  • - + {% trans "Allocate Stock" %}
  • {% endif %} -
  • + {% if not build.is_complete %} +
  • - - {% trans "Build Outputs" %} + + {% trans "Pending Outputs" %} + +
  • + {% endif %} + +
  • + + + {% trans "Completed Outputs" %}
  • diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index 017f0126c5..e2b6448f2f 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -7,6 +7,7 @@ from django.urls import reverse from part.models import Part from build.models import Build, BuildItem +from stock.models import StockItem from InvenTree.status_codes import BuildStatus from InvenTree.api_tester import InvenTreeAPITestCase @@ -37,6 +38,148 @@ class BuildAPITest(InvenTreeAPITestCase): super().setUp() +class BuildCompleteTest(BuildAPITest): + """ + Unit testing for the build complete API endpoint + """ + + def setUp(self): + + super().setUp() + + self.build = Build.objects.get(pk=1) + + self.url = reverse('api-build-complete', kwargs={'pk': self.build.pk}) + + def test_invalid(self): + """ + Test with invalid data + """ + + # Test with an invalid build ID + self.post( + reverse('api-build-complete', kwargs={'pk': 99999}), + {}, + expected_code=400 + ) + + data = self.post(self.url, {}, expected_code=400).data + + self.assertIn("This field is required", str(data['outputs'])) + self.assertIn("This field is required", str(data['location'])) + + # Test with an invalid location + data = self.post( + self.url, + { + "outputs": [], + "location": 999999, + }, + expected_code=400 + ).data + + self.assertIn( + "Invalid pk", + str(data["location"]) + ) + + data = self.post( + self.url, + { + "outputs": [], + "location": 1, + }, + expected_code=400 + ).data + + self.assertIn("A list of build outputs must be provided", str(data)) + + stock_item = StockItem.objects.create( + part=self.build.part, + quantity=100, + ) + + post_data = { + "outputs": [ + { + "output": stock_item.pk, + }, + ], + "location": 1, + } + + # Post with a stock item that does not match the build + data = self.post( + self.url, + post_data, + expected_code=400 + ).data + + self.assertIn( + "Build output does not match the parent build", + str(data["outputs"][0]) + ) + + # Now, ensure that the stock item *does* match the build + stock_item.build = self.build + stock_item.save() + + data = self.post( + self.url, + post_data, + expected_code=400, + ).data + + self.assertIn( + "This build output has already been completed", + str(data["outputs"][0]["output"]) + ) + + def test_complete(self): + """ + Test build order completion + """ + + # We start without any outputs assigned against the build + self.assertEqual(self.build.incomplete_outputs.count(), 0) + + # Create some more build outputs + for ii in range(10): + self.build.create_build_output(10) + + # Check that we are in a known state + self.assertEqual(self.build.incomplete_outputs.count(), 10) + self.assertEqual(self.build.incomplete_count, 100) + self.assertEqual(self.build.completed, 0) + + # We shall complete 4 of these outputs + outputs = self.build.incomplete_outputs[0:4] + + self.post( + self.url, + { + "outputs": [{"output": output.pk} for output in outputs], + "location": 1, + "status": 50, # Item requires attention + }, + expected_code=201 + ) + + # There now should be 6 incomplete build outputs remaining + self.assertEqual(self.build.incomplete_outputs.count(), 6) + + # And there should be 4 completed outputs + outputs = self.build.complete_outputs + self.assertEqual(outputs.count(), 4) + + for output in outputs: + self.assertFalse(output.is_building) + self.assertEqual(output.build, self.build) + + self.build.refresh_from_db() + self.assertEqual(self.build.completed, 40) + + class BuildAllocationTest(BuildAPITest): """ Unit tests for allocation of stock items against a build order. diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index df6253362e..f8c381f224 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -339,11 +339,11 @@ class BuildTest(TestCase): self.assertTrue(self.build.isFullyAllocated(self.output_1)) self.assertTrue(self.build.isFullyAllocated(self.output_2)) - self.build.completeBuildOutput(self.output_1, None) + self.build.complete_build_output(self.output_1, None) self.assertFalse(self.build.can_complete) - self.build.completeBuildOutput(self.output_2, None) + self.build.complete_build_output(self.output_2, None) self.assertTrue(self.build.can_complete) diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py index 7b2568b1c7..7afd078ce9 100644 --- a/InvenTree/build/tests.py +++ b/InvenTree/build/tests.py @@ -15,7 +15,7 @@ from datetime import datetime, timedelta from .models import Build from stock.models import StockItem -from InvenTree.status_codes import BuildStatus, StockStatus +from InvenTree.status_codes import BuildStatus class BuildTestSimple(TestCase): @@ -252,53 +252,6 @@ class TestBuildViews(TestCase): self.assertIn(build.title, content) - def test_build_output_complete(self): - """ - Test the build output completion form - """ - - # Firstly, check that the build cannot be completed! - self.assertFalse(self.build.can_complete) - - url = reverse('build-output-complete', args=(1,)) - - # Test without confirmation - response = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - data = json.loads(response.content) - self.assertFalse(data['form_valid']) - - # Test with confirmation, valid location - response = self.client.post( - url, - { - 'confirm': 1, - 'confirm_incomplete': 1, - 'location': 1, - 'output': self.output.pk, - 'stock_status': StockStatus.DAMAGED - }, - HTTP_X_REQUESTED_WITH='XMLHttpRequest' - ) - - self.assertEqual(response.status_code, 200) - - data = json.loads(response.content) - - self.assertTrue(data['form_valid']) - - # Now the build should be able to be completed - self.build.refresh_from_db() - self.assertTrue(self.build.can_complete) - - # Test with confirmation, invalid location - response = self.client.post(url, {'confirm': 1, 'location': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - data = json.loads(response.content) - self.assertFalse(data['form_valid']) - def test_build_cancel(self): """ Test the build cancellation form """ diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index d80b16056c..8ea339ae26 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -11,7 +11,6 @@ build_detail_urls = [ url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'), 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'^complete/', views.BuildComplete.as_view(), name='build-complete'), url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 8c63c1296c..fd730b6a7e 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -12,16 +12,17 @@ from django.forms import HiddenInput from .models import Build from . import forms -from stock.models import StockLocation, StockItem +from stock.models import StockItem from InvenTree.views import AjaxUpdateView, AjaxDeleteView from InvenTree.views import InvenTreeRoleMixin from InvenTree.helpers import str2bool, extract_serial_numbers -from InvenTree.status_codes import BuildStatus, StockStatus +from InvenTree.status_codes import BuildStatus class BuildIndex(InvenTreeRoleMixin, ListView): - """ View for displaying list of Builds + """ + View for displaying list of Builds """ model = Build template_name = 'build/index.html' @@ -278,178 +279,10 @@ class BuildComplete(AjaxUpdateView): } -class BuildOutputComplete(AjaxUpdateView): - """ - View to mark a particular build output as Complete. - - - Notifies the user of which parts will be removed from stock. - - Assignes (tracked) allocated items from stock to the build output - - Deletes pending BuildItem objects - """ - - model = Build - form_class = forms.CompleteBuildOutputForm - context_object_name = "build" - ajax_form_title = _("Complete Build Output") - ajax_template_name = "build/complete_output.html" - - def get_form(self): - - build = self.get_object() - - form = super().get_form() - - # Extract the build output object - output = None - output_id = form['output'].value() - - try: - output = StockItem.objects.get(pk=output_id) - except (ValueError, StockItem.DoesNotExist): - pass - - if output: - if build.isFullyAllocated(output): - form.fields['confirm_incomplete'].widget = HiddenInput() - - return form - - def validate(self, build, form, **kwargs): - """ - Custom validation steps for the BuildOutputComplete" form - """ - - data = form.cleaned_data - - output = data.get('output', None) - - stock_status = data.get('stock_status', StockStatus.OK) - - # Any "invalid" stock status defaults to OK - try: - stock_status = int(stock_status) - except (ValueError): - stock_status = StockStatus.OK - - if int(stock_status) not in StockStatus.keys(): - form.add_error('stock_status', _('Invalid stock status value selected')) - - if output: - - quantity = data.get('quantity', None) - - if quantity and quantity > output.quantity: - form.add_error('quantity', _('Quantity to complete cannot exceed build output quantity')) - - if not build.isFullyAllocated(output): - confirm = str2bool(data.get('confirm_incomplete', False)) - - if not confirm: - form.add_error('confirm_incomplete', _('Confirm completion of incomplete build')) - - else: - form.add_error(None, _('Build output must be specified')) - - def get_initial(self): - """ Get initial form data for the CompleteBuild form - - - If the part being built has a default location, pre-select that location - """ - - initials = super().get_initial() - build = self.get_object() - - if build.part.default_location is not None: - try: - location = StockLocation.objects.get(pk=build.part.default_location.id) - initials['location'] = location - except StockLocation.DoesNotExist: - pass - - output = self.get_param('output', None) - - if output: - try: - output = StockItem.objects.get(pk=output) - except (ValueError, StockItem.DoesNotExist): - output = None - - # Output has not been supplied? Try to "guess" - if not output: - - incomplete = build.get_build_outputs(complete=False) - - if incomplete.count() == 1: - output = incomplete[0] - - if output is not None: - initials['output'] = output - - initials['location'] = build.destination - - return initials - - def get_context_data(self, **kwargs): - """ - Get context data for passing to the rendered form - - - Build information is required - """ - - build = self.get_object() - - context = {} - - # Build object - context['build'] = build - - form = self.get_form() - - output = form['output'].value() - - if output: - try: - output = StockItem.objects.get(pk=output) - context['output'] = output - context['fully_allocated'] = build.isFullyAllocated(output) - context['allocated_parts'] = build.allocatedParts(output) - context['unallocated_parts'] = build.unallocatedParts(output) - except (ValueError, StockItem.DoesNotExist): - pass - - return context - - def save(self, build, form, **kwargs): - - data = form.cleaned_data - - location = data.get('location', None) - output = data.get('output', None) - stock_status = data.get('stock_status', StockStatus.OK) - - # Any "invalid" stock status defaults to OK - try: - stock_status = int(stock_status) - except (ValueError): - stock_status = StockStatus.OK - - # Complete the build output - build.completeBuildOutput( - output, - self.request.user, - location=location, - status=stock_status, - ) - - def get_data(self): - """ Provide feedback data back to the form """ - return { - 'success': _('Build output completed') - } - - class BuildDetail(InvenTreeRoleMixin, DetailView): - """ Detail view of a single Build object. """ + """ + Detail view of a single Build object. + """ model = Build template_name = 'build/detail.html' @@ -477,7 +310,9 @@ class BuildDetail(InvenTreeRoleMixin, DetailView): class BuildDelete(AjaxDeleteView): - """ View to delete a build """ + """ + View to delete a build + """ model = Build ajax_template_name = 'build/delete_build.html' diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 81fee4ff65..551515733d 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -998,6 +998,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): 'validator': [int, MinValueValidator(1)] }, + 'SEARCH_SHOW_STOCK_LEVELS': { + 'name': _('Search Show Stock'), + 'description': _('Display stock levels in search preview window'), + 'default': True, + 'validator': bool, + }, + 'PART_SHOW_QUANTITY_IN_FORMS': { 'name': _('Show Quantity in Forms'), 'description': _('Display available part quantity in some forms'), diff --git a/InvenTree/common/templates/common/delete_currency.html b/InvenTree/common/templates/common/delete_currency.html deleted file mode 100644 index 9dfa320668..0000000000 --- a/InvenTree/common/templates/common/delete_currency.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "modal_delete_form.html" %} - -{% block pre_form_content %} - -Are you sure you wish to delete this currency? - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index df0ec1a5de..f4ebff4dfb 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -5,7 +5,6 @@ JSON API for the Order 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 django.db.models import Q, F @@ -13,7 +12,6 @@ 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.serializers import ValidationError from InvenTree.filters import InvenTreeOrderingFilter @@ -236,25 +234,15 @@ class POReceive(generics.CreateAPIView): context = super().get_serializer_context() # Pass the purchase order through to the serializer for validation - context['order'] = self.get_order() + try: + context['order'] = PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + context['request'] = self.request return context - def get_order(self): - """ - Returns the PurchaseOrder associated with this API endpoint - """ - - pk = self.kwargs.get('pk', None) - - try: - order = PurchaseOrder.objects.get(pk=pk) - except (PurchaseOrder.DoesNotExist, ValueError): - raise ValidationError(_("Matching purchase order does not exist")) - - return order - class POLineItemFilter(rest_filters.FilterSet): """ diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 227109c46c..6f0bc43c46 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -9,7 +9,7 @@ from django import forms from django.utils.translation import ugettext_lazy as _ from InvenTree.forms import HelperForm -from InvenTree.fields import InvenTreeMoneyField, RoundingDecimalFormField +from InvenTree.fields import InvenTreeMoneyField from InvenTree.helpers import clean_decimal @@ -19,7 +19,6 @@ import part.models from .models import PurchaseOrder from .models import SalesOrder, SalesOrderLineItem -from .models import SalesOrderAllocation class IssuePurchaseOrderForm(HelperForm): @@ -81,6 +80,8 @@ class AllocateSerialsToSalesOrderForm(forms.Form): """ Form for assigning stock to a sales order, by serial number lookup + + TODO: Refactor this form / view to use the new API forms interface """ line = forms.ModelChoiceField( @@ -115,22 +116,6 @@ class AllocateSerialsToSalesOrderForm(forms.Form): ] -class EditSalesOrderAllocationForm(HelperForm): - """ - Form for editing a SalesOrderAllocation item - """ - - quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity')) - - class Meta: - model = SalesOrderAllocation - - fields = [ - 'line', - 'item', - 'quantity'] - - class OrderMatchItemForm(MatchItemForm): """ Override MatchItemForm fields """ diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 8b98755900..ca7e70c4e3 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -36,31 +36,39 @@ src="{% static 'img/blank_image.png' %}"

    {{ order.description }}{% include "clip.html"%}

    - - + + {% if roles.purchase_order.change %} - + +
    + + +
    {% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %} {% elif order.status == PurchaseOrderStatus.PLACED %} - {% endif %} - {% if order.can_cancel %} - {% endif %} {% endif %} diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 3fd34e42b9..f7595cc182 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -47,30 +47,39 @@ src="{% static 'img/blank_image.png' %}"

    {{ order.description }}{% include "clip.html"%}

    - - + + {% if roles.sales_order.change %} - + +
    + + + +
    {% if order.status == SalesOrderStatus.PENDING %} - {% endif %} {% endif %} -
    {% endblock %} diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 1f7905d1e3..899fa9a6fc 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -203,6 +203,23 @@ class PurchaseOrderTest(OrderTest): # And if we try to access the detail view again, it has gone response = self.get(url, expected_code=404) + def test_po_create(self): + """ + Test that we can create a new PurchaseOrder via the API + """ + + self.assignRole('purchase_order.add') + + self.post( + reverse('api-po-list'), + { + 'reference': '12345678', + 'supplier': 1, + 'description': 'A test purchase order', + }, + expected_code=201 + ) + class PurchaseOrderReceiveTest(OrderTest): """ @@ -607,3 +624,20 @@ class SalesOrderTest(OrderTest): # And the resource should no longer be available response = self.get(url, expected_code=404) + + def test_so_create(self): + """ + Test that we can create a new SalesOrder via the API + """ + + self.assignRole('sales_order.add') + + self.post( + reverse('api-so-list'), + { + 'reference': '1234566778', + 'customer': 4, + 'description': 'A test sales order', + }, + expected_code=201 + ) diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index f5d7d39266..ddcb78ac2a 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -18,7 +18,7 @@ import common.models from common.forms import MatchItemForm from .models import Part, PartCategory, PartRelated -from .models import PartParameterTemplate, PartParameter +from .models import PartParameterTemplate from .models import PartCategoryParameterTemplate from .models import PartSellPriceBreak, PartInternalPriceBreak @@ -188,18 +188,6 @@ class EditPartParameterTemplateForm(HelperForm): ] -class EditPartParameterForm(HelperForm): - """ Form for editing a PartParameter object """ - - class Meta: - model = PartParameter - fields = [ - 'part', - 'template', - 'data' - ] - - class EditCategoryForm(HelperForm): """ Form for editing a PartCategory object """ diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 7e739306b0..96b6e0ba91 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -16,8 +16,6 @@ from InvenTree.forms import HelperForm from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import DatePickerFormField -from report.models import TestReport - from part.models import Part from .models import StockLocation, StockItem, StockItemTracking @@ -26,6 +24,8 @@ from .models import StockLocation, StockItem, StockItemTracking class AssignStockItemToCustomerForm(HelperForm): """ Form for manually assigning a StockItem to a Customer + + TODO: This could be a simple API driven form! """ class Meta: @@ -38,6 +38,8 @@ class AssignStockItemToCustomerForm(HelperForm): class ReturnStockItemForm(HelperForm): """ Form for manually returning a StockItem into stock + + TODO: This could be a simple API driven form! """ class Meta: @@ -48,7 +50,11 @@ class ReturnStockItemForm(HelperForm): class EditStockLocationForm(HelperForm): - """ Form for editing a StockLocation """ + """ + Form for editing a StockLocation + + TODO: Migrate this form to the modern API forms interface + """ class Meta: model = StockLocation @@ -63,6 +69,8 @@ class EditStockLocationForm(HelperForm): class ConvertStockItemForm(HelperForm): """ Form for converting a StockItem to a variant of its current part. + + TODO: Migrate this form to the modern API forms interface """ class Meta: @@ -73,7 +81,11 @@ class ConvertStockItemForm(HelperForm): class CreateStockItemForm(HelperForm): - """ Form for creating a new StockItem """ + """ + Form for creating a new StockItem + + TODO: Migrate this form to the modern API forms interface + """ expiry_date = DatePickerFormField( label=_('Expiry Date'), @@ -129,7 +141,11 @@ class CreateStockItemForm(HelperForm): class SerializeStockForm(HelperForm): - """ Form for serializing a StockItem. """ + """ + Form for serializing a StockItem. + + TODO: Migrate this form to the modern API forms interface + """ destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Destination'), required=True, help_text=_('Destination for serialized stock (by default, will remain in current location)')) @@ -160,73 +176,11 @@ class SerializeStockForm(HelperForm): ] -class StockItemLabelSelectForm(HelperForm): - """ Form for selecting a label template for a StockItem """ - - label = forms.ChoiceField( - label=_('Label'), - help_text=_('Select test report template') - ) - - class Meta: - model = StockItem - fields = [ - 'label', - ] - - def get_label_choices(self, labels): - - choices = [] - - if len(labels) > 0: - for label in labels: - choices.append((label.pk, label)) - - return choices - - def __init__(self, labels, *args, **kwargs): - - super().__init__(*args, **kwargs) - - self.fields['label'].choices = self.get_label_choices(labels) - - -class TestReportFormatForm(HelperForm): - """ Form for selection a test report template """ - - class Meta: - model = StockItem - fields = [ - 'template', - ] - - def __init__(self, stock_item, *args, **kwargs): - self.stock_item = stock_item - - super().__init__(*args, **kwargs) - self.fields['template'].choices = self.get_template_choices() - - def get_template_choices(self): - """ - Generate a list of of TestReport options for the StockItem - """ - - choices = [] - - templates = TestReport.objects.filter(enabled=True) - - for template in templates: - if template.enabled and template.matches_stock_item(self.stock_item): - choices.append((template.pk, template)) - - return choices - - template = forms.ChoiceField(label=_('Template'), help_text=_('Select test report template')) - - class InstallStockForm(HelperForm): """ Form for manually installing a stock item into another stock item + + TODO: Migrate this form to the modern API forms interface """ part = forms.ModelChoiceField( @@ -275,6 +229,8 @@ class InstallStockForm(HelperForm): class UninstallStockForm(forms.ModelForm): """ Form for uninstalling a stock item which is installed in another item. + + TODO: Migrate this form to the modern API forms interface """ location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label=_('Location'), help_text=_('Destination location for uninstalled items')) @@ -301,6 +257,8 @@ class EditStockItemForm(HelperForm): location - Must be updated in a 'move' transaction quantity - Must be updated in a 'stocktake' transaction part - Cannot be edited after creation + + TODO: Migrate this form to the modern API forms interface """ expiry_date = DatePickerFormField( diff --git a/InvenTree/stock/templates/stock/stock_adjust.html b/InvenTree/stock/templates/stock/stock_adjust.html deleted file mode 100644 index 60a9ec2658..0000000000 --- a/InvenTree/stock/templates/stock/stock_adjust.html +++ /dev/null @@ -1,50 +0,0 @@ -{% load i18n %} -{% load inventree_extras %} - -{% block pre_form_content %} - -{% endblock %} - -
    - {% csrf_token %} - {% load crispy_forms_tags %} - - - - - - - - - {% if edit_quantity %} - - {% endif %} - - - {% for item in stock_items %} - - - - - - - - {% endfor %} -
    {% trans "Stock Item" %}{% trans "Location" %}{% trans "Quantity" %}{{ stock_action_title }}
    {% include "hover_image.html" with image=item.part.image hover=True %} - {{ item.part.full_name }} {{ item.part.description }}{{ item.location.pathstring }}{% decimal item.quantity %} - {% if edit_quantity %} - - {% if item.error %} -
    {{ item.error }} - {% endif %} - {% else %} - - {% endif %} -
    - - {% crispy form %} - -
    \ No newline at end of file diff --git a/InvenTree/stock/templates/stock/stock_move.html b/InvenTree/stock/templates/stock/stock_move.html deleted file mode 100644 index c7de8c74b2..0000000000 --- a/InvenTree/stock/templates/stock/stock_move.html +++ /dev/null @@ -1 +0,0 @@ -{% extends "modal_form.html" %} \ No newline at end of file diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html index 14fc31531a..81264305c5 100644 --- a/InvenTree/templates/InvenTree/index.html +++ b/InvenTree/templates/InvenTree/index.html @@ -12,12 +12,14 @@
    -
      -
    +
    +
      +
    +
      -
    • +
    • @@ -54,7 +56,7 @@ function addHeaderAction(label, title, icon, options) { // Add a detail item to the detail item-panel $("#detail-item-list").append( - `
    • + `
    • ${title}

    • ` diff --git a/InvenTree/templates/InvenTree/search.html b/InvenTree/templates/InvenTree/search.html index bc1eb013bf..d1d22e420a 100644 --- a/InvenTree/templates/InvenTree/search.html +++ b/InvenTree/templates/InvenTree/search.html @@ -26,12 +26,14 @@ {% endif %}
      -
        -
      +
      +
        +
      +
        -
      • +
      • @@ -67,7 +69,7 @@ // Add a results table $('#search-result-list').append( - `
      • + `
      • ${title}

      • ` diff --git a/InvenTree/templates/InvenTree/settings/navbar.html b/InvenTree/templates/InvenTree/settings/navbar.html index 58673d6618..6c956761a2 100644 --- a/InvenTree/templates/InvenTree/settings/navbar.html +++ b/InvenTree/templates/InvenTree/settings/navbar.html @@ -9,12 +9,12 @@
      • - {% trans "User Settings" %} + {% trans "User Settings" %}
      • - {% trans "Account" %} + {% trans "Account" %}
      • @@ -59,7 +59,7 @@ {% if user.is_staff %}
      • - {% trans "InvenTree Settings" %} + {% trans "Global Settings" %}
      • diff --git a/InvenTree/templates/InvenTree/settings/user_search.html b/InvenTree/templates/InvenTree/settings/user_search.html index c06bfaec8d..1779445e64 100644 --- a/InvenTree/templates/InvenTree/settings/user_search.html +++ b/InvenTree/templates/InvenTree/settings/user_search.html @@ -16,6 +16,7 @@ {% include "InvenTree/settings/header.html" %} {% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_RESULTS" user_setting=True icon='fa-search' %} + {% include "InvenTree/settings/setting.html" with key="SEARCH_SHOW_STOCK_LEVELS" user_setting=True icon='fa-boxes' %}
      diff --git a/InvenTree/templates/attachment_delete.html b/InvenTree/templates/attachment_delete.html deleted file mode 100644 index 4ee7f03cb1..0000000000 --- a/InvenTree/templates/attachment_delete.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "modal_delete_form.html" %} -{% load i18n %} - -{% block pre_form_content %} -{% trans "Are you sure you want to delete this attachment?" %} -
      -{% endblock %} \ No newline at end of file diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index c2316ce4b0..98d83aa2d9 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -143,7 +143,6 @@ - diff --git a/InvenTree/templates/collapse.html b/InvenTree/templates/collapse.html deleted file mode 100644 index 5624f34094..0000000000 --- a/InvenTree/templates/collapse.html +++ /dev/null @@ -1,23 +0,0 @@ -{% block collapse_preamble %} -{% endblock %} -
      -
      -
      -
      - - {% block collapse_heading %} - {% endblock %} -
      -
      -
      -
      - {% block collapse_content %} - {% endblock %} -
      -
      -
      -
      \ No newline at end of file diff --git a/InvenTree/templates/collapse_index.html b/InvenTree/templates/collapse_index.html deleted file mode 100644 index 6e918d7217..0000000000 --- a/InvenTree/templates/collapse_index.html +++ /dev/null @@ -1,19 +0,0 @@ -{% block collapse_preamble %} -{% endblock %} -
      -
      -
      - - {% block collapse_heading %} - {% endblock %} -
      -
      -
      - {% block collapse_content %} - {% endblock %} -
      -
      -
      -
      \ No newline at end of file diff --git a/InvenTree/templates/js/dynamic/inventree.js b/InvenTree/templates/js/dynamic/inventree.js index 0324d72e3c..4766565d3c 100644 --- a/InvenTree/templates/js/dynamic/inventree.js +++ b/InvenTree/templates/js/dynamic/inventree.js @@ -140,11 +140,13 @@ function inventreeDocReady() { offset: 0 }, success: function(data) { + var transformed = $.map(data.results, function(el) { return { label: el.full_name, id: el.pk, - thumbnail: el.thumbnail + thumbnail: el.thumbnail, + data: el, }; }); response(transformed); @@ -164,7 +166,18 @@ function inventreeDocReady() { html += `'> `; html += item.label; - html += ''; + html += ''; + + if (user_settings.SEARCH_SHOW_STOCK_LEVELS) { + html += partStockLabel( + item.data, + { + label_class: 'label-right', + } + ); + } + + html += ''; return $('
    • ').append(html).appendTo(ul); }; @@ -290,3 +303,8 @@ function loadBrandIcon(element, name) { element.addClass('fab fa-' + name); } } + +// Convenience function to determine if an element exists +$.fn.exists = function() { + return this.length !== 0; +}; diff --git a/InvenTree/templates/js/dynamic/nav.js b/InvenTree/templates/js/dynamic/nav.js index cf652724ed..ddf3cb8c12 100644 --- a/InvenTree/templates/js/dynamic/nav.js +++ b/InvenTree/templates/js/dynamic/nav.js @@ -3,6 +3,9 @@ /* exported attachNavCallbacks, + enableNavbar, + initNavTree, + loadTree, onPanelLoad, */ @@ -113,3 +116,253 @@ function onPanelLoad(panel, callback) { }); } + +function loadTree(url, tree, options={}) { + /* Load the side-nav tree view + + Args: + url: URL to request tree data + tree: html ref to treeview + options: + data: data object to pass to the AJAX request + selected: ID of currently selected item + name: name of the tree + */ + + var data = {}; + + if (options.data) { + data = options.data; + } + + var key = 'inventree-sidenav-items-'; + + if (options.name) { + key += options.name; + } + + $.ajax({ + url: url, + type: 'get', + dataType: 'json', + data: data, + success: function(response) { + if (response.tree) { + $(tree).treeview({ + data: response.tree, + enableLinks: true, + showTags: true, + }); + + if (localStorage.getItem(key)) { + var saved_exp = localStorage.getItem(key).split(','); + + // Automatically expand the desired notes + for (var q = 0; q < saved_exp.length; q++) { + $(tree).treeview('expandNode', parseInt(saved_exp[q])); + } + } + + // Setup a callback whenever a node is toggled + $(tree).on('nodeExpanded nodeCollapsed', function(event, data) { + + // Record the entire list of expanded items + var expanded = $(tree).treeview('getExpanded'); + + var exp = []; + + for (var i = 0; i < expanded.length; i++) { + exp.push(expanded[i].nodeId); + } + + // Save the expanded nodes + localStorage.setItem(key, exp); + }); + } + }, + error: function(xhr, ajaxOptions, thrownError) { + // TODO + } + }); +} + + +/** + * Initialize navigation tree display + */ +function initNavTree(options) { + + var resize = true; + + if ('resize' in options) { + resize = options.resize; + } + + var label = options.label || 'nav'; + + var stateLabel = `${label}-tree-state`; + var widthLabel = `${label}-tree-width`; + + var treeId = options.treeId || '#sidenav-left'; + var toggleId = options.toggleId; + + // Initially hide the tree + $(treeId).animate({ + width: '0px', + }, 0, function() { + + if (resize) { + $(treeId).resizable({ + minWidth: '0px', + maxWidth: '500px', + handles: 'e, se', + grid: [5, 5], + stop: function(event, ui) { + var width = Math.round(ui.element.width()); + + if (width < 75) { + $(treeId).animate({ + width: '0px' + }, 50); + + localStorage.setItem(stateLabel, 'closed'); + } else { + localStorage.setItem(stateLabel, 'open'); + localStorage.setItem(widthLabel, `${width}px`); + } + } + }); + } + + var state = localStorage.getItem(stateLabel); + var width = localStorage.getItem(widthLabel) || '300px'; + + if (state && state == 'open') { + + $(treeId).animate({ + width: width, + }, 50); + } + }); + + // Register callback for 'toggle' button + if (toggleId) { + + $(toggleId).click(function() { + + var state = localStorage.getItem(stateLabel) || 'closed'; + var width = localStorage.getItem(widthLabel) || '300px'; + + if (state == 'open') { + $(treeId).animate({ + width: '0px' + }, 50); + + localStorage.setItem(stateLabel, 'closed'); + } else { + $(treeId).animate({ + width: width, + }, 50); + + localStorage.setItem(stateLabel, 'open'); + } + }); + } +} + + +/** + * Handle left-hand icon menubar display + */ +function enableNavbar(options) { + + var resize = true; + + if ('resize' in options) { + resize = options.resize; + } + + var label = options.label || 'nav'; + + label = `navbar-${label}`; + + var stateLabel = `${label}-state`; + var widthLabel = `${label}-width`; + + var navId = options.navId || '#sidenav-right'; + + var toggleId = options.toggleId; + + // Extract the saved width for this element + $(navId).animate({ + 'width': '45px', + 'min-width': '45px', + 'display': 'block', + }, 50, function() { + + // Make the navbar resizable + if (resize) { + $(navId).resizable({ + minWidth: options.minWidth || '100px', + maxWidth: options.maxWidth || '500px', + handles: 'e, se', + grid: [5, 5], + stop: function(event, ui) { + // Record the new width + var width = Math.round(ui.element.width()); + + // Reasonably narrow? Just close it! + if (width <= 75) { + $(navId).animate({ + width: '45px' + }, 50); + + localStorage.setItem(stateLabel, 'closed'); + } else { + localStorage.setItem(widthLabel, `${width}px`); + localStorage.setItem(stateLabel, 'open'); + } + } + }); + } + + var state = localStorage.getItem(stateLabel); + + var width = localStorage.getItem(widthLabel) || '250px'; + + if (state && state == 'open') { + + $(navId).animate({ + width: width + }, 100); + } + + }); + + // Register callback for 'toggle' button + if (toggleId) { + + $(toggleId).click(function() { + + var state = localStorage.getItem(stateLabel) || 'closed'; + var width = localStorage.getItem(widthLabel) || '250px'; + + if (state == 'open') { + $(navId).animate({ + width: '45px', + minWidth: '45px', + }, 50); + + localStorage.setItem(stateLabel, 'closed'); + + } else { + + $(navId).animate({ + 'width': width + }, 50); + + localStorage.setItem(stateLabel, 'open'); + } + }); + } +} diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index d3499deedf..b6c98fc49e 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -24,7 +24,7 @@ loadAllocationTable, loadBuildOrderAllocationTable, loadBuildOutputAllocationTable, - loadBuildPartsTable, + loadBuildOutputTable, loadBuildTable, */ @@ -108,126 +108,56 @@ function newBuildOrder(options={}) { } -function makeBuildOutputActionButtons(output, buildInfo, lines) { - /* Generate action buttons for a build output. - */ - - var buildId = buildInfo.pk; - var partId = buildInfo.part; - - var outputId = 'untracked'; - - if (output) { - outputId = output.pk; - } - - var panel = `#allocation-panel-${outputId}`; - - function reloadTable() { - $(panel).find(`#allocation-table-${outputId}`).bootstrapTable('refresh'); - } - - // Find the div where the buttons will be displayed - var buildActions = $(panel).find(`#output-actions-${outputId}`); - +/* + * Construct a set of output buttons for a particular build output + */ +function makeBuildOutputButtons(output_id, build_info, options={}) { + var html = `
      `; - if (lines > 0) { - html += makeIconButton( - 'fa-sign-in-alt icon-blue', 'button-output-auto', outputId, - '{% trans "Allocate stock items to this build output" %}', - ); - } + // Tracked parts? Must be individually allocated + if (build_info.tracked_parts) { - if (lines > 0) { - // Add a button to "cancel" the particular build output (unallocate) + // Add a button to allocate stock against this build output html += makeIconButton( - 'fa-minus-circle icon-red', 'button-output-unallocate', outputId, + 'fa-sign-in-alt icon-blue', + 'button-output-allocate', + output_id, + '{% trans "Allocate stock items to this build output" %}', + { + disabled: true, + } + ); + + // Add a button to unallocate stock from this build output + html += makeIconButton( + 'fa-minus-circle icon-red', + 'button-output-unallocate', + output_id, '{% trans "Unallocate stock from build output" %}', ); } - if (output) { + // Add a button to "complete" this build output + html += makeIconButton( + 'fa-check-circle icon-green', + 'button-output-complete', + output_id, + '{% trans "Complete build output" %}', + ); - // Add a button to "complete" the particular build output - html += makeIconButton( - 'fa-check icon-green', 'button-output-complete', outputId, - '{% trans "Complete build output" %}', - { - // disabled: true - } - ); + // Add a button to "delete" this build output + html += makeIconButton( + 'fa-trash-alt icon-red', + 'button-output-delete', + output_id, + '{% trans "Delete build output" %}', + ); - // Add a button to "delete" the particular build output - html += makeIconButton( - 'fa-trash-alt icon-red', 'button-output-delete', outputId, - '{% trans "Delete build output" %}', - ); + html += `
      `; - // TODO - Add a button to "destroy" the particular build output (mark as damaged, scrap) - } + return html; - html += '
    '; - - buildActions.html(html); - - // 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 - allocateStockToBuild( - buildId, - partId, - bom_items, - { - source_location: buildInfo.source_location, - output: outputId, - success: reloadTable, - } - ); - }); - - $(panel).find(`#button-output-complete-${outputId}`).click(function() { - - var pk = $(this).attr('pk'); - - launchModalForm( - `/build/${buildId}/complete-output/`, - { - data: { - output: pk, - }, - reload: true, - } - ); - }); - - $(panel).find(`#button-output-unallocate-${outputId}`).click(function() { - - var pk = $(this).attr('pk'); - - unallocateStock(buildId, { - output: pk, - table: table, - }); - }); - - $(panel).find(`#button-output-delete-${outputId}`).click(function() { - - var pk = $(this).attr('pk'); - - launchModalForm( - `/build/${buildId}/delete-output/`, - { - reload: true, - data: { - output: pk - } - } - ); - }); } @@ -270,14 +200,160 @@ function unallocateStock(build_id, options={}) { } } }); - } +/** + * Launch a modal form to complete selected build outputs + */ +function completeBuildOutputs(build_id, outputs, options={}) { + + if (outputs.length == 0) { + showAlertDialog( + '{% trans "Select Build Outputs" %}', + '{% trans "At least one build output must be selected" %}', + ); + return; + } + + // Render a single build output (StockItem) + function renderBuildOutput(output, opts={}) { + var pk = output.pk; + + var output_html = imageHoverIcon(output.part_detail.thumbnail); + + if (output.quantity == 1 && output.serial) { + output_html += `{% trans "Serial Number" %}: ${output.serial}`; + } else { + output_html += `{% trans "Quantity" %}: ${output.quantity}`; + } + + var buttons = `
    `; + + buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove row" %}'); + + buttons += '
    '; + + var field = constructField( + `outputs_output_${pk}`, + { + type: 'raw', + html: output_html, + }, + { + hideLabels: true, + } + ); + + var html = ` + + ${field} + ${output.part_detail.full_name} + ${buttons} + `; + + return html; + } + + // Construct table entries + var table_entries = ''; + + outputs.forEach(function(output) { + table_entries += renderBuildOutput(output); + }); + + var html = ` + + + + + + + ${table_entries} + +
    {% trans "Output" %}
    `; + + constructForm(`/api/build/${build_id}/complete/`, { + method: 'POST', + preFormContent: html, + fields: { + status: {}, + location: {}, + }, + confirm: true, + title: '{% trans "Complete Build Outputs" %}', + afterRender: function(fields, opts) { + // Setup callbacks to remove outputs + $(opts.modal).find('.button-row-remove').click(function() { + var pk = $(this).attr('pk'); + + $(opts.modal).find(`#output_row_${pk}`).remove(); + }); + }, + onSubmit: function(fields, opts) { + + // Extract data elements from the form + var data = { + outputs: [], + status: getFormFieldValue('status', {}, opts), + location: getFormFieldValue('location', {}, opts), + }; + + var output_pk_values = []; + + outputs.forEach(function(output) { + var pk = output.pk; + + var row = $(opts.modal).find(`#output_row_${pk}`); + + if (row.exists()) { + data.outputs.push({ + output: pk, + }); + output_pk_values.push(pk); + } + }); + + // Provide list of nested values + opts.nested = { + 'outputs': output_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; + } + } + } + ); + } + }); +} + + +/** + * Load a table showing all the BuildOrder allocations for a given part + */ function loadBuildOrderAllocationTable(table, options={}) { - /** - * Load a table showing all the BuildOrder allocations for a given part - */ options.params['part_detail'] = true; options.params['build_detail'] = true; @@ -357,17 +433,256 @@ function loadBuildOrderAllocationTable(table, options={}) { } -function loadBuildOutputAllocationTable(buildInfo, output, options={}) { +/* + * Display a "build output" table for a particular build. + * + * This displays a list of "active" (i.e. "in production") build outputs for a given build + * + */ +function loadBuildOutputTable(build_info, options={}) { + + var table = options.table || '#build-output-table'; + + var params = options.params || {}; + + // Mandatory query filters + params.part_detail = true; + params.is_building = true; + params.build = build_info.pk; + + // Construct a list of "tracked" BOM items + var tracked_bom_items = []; + + var has_tracked_items = false; + + build_info.bom_items.forEach(function(bom_item) { + if (bom_item.sub_part_detail.trackable) { + tracked_bom_items.push(bom_item); + has_tracked_items = true; + }; + }); + + var filters = {}; + + for (var key in params) { + filters[key] = params[key]; + } + + // TODO: Initialize filter list + + function setupBuildOutputButtonCallbacks() { + + // Callback for the "allocate" button + $(table).find('.button-output-allocate').click(function() { + var pk = $(this).attr('pk'); + + // Find the "allocation" sub-table associated with this output + var subtable = $(`#output-sub-table-${pk}`); + + if (subtable.exists()) { + var rows = subtable.bootstrapTable('getSelections'); + + // None selected? Use all! + if (rows.length == 0) { + rows = subtable.bootstrapTable('getData'); + } + + allocateStockToBuild( + build_info.pk, + build_info.part, + rows, + { + output: pk, + success: function() { + $(table).bootstrapTable('refresh'); + } + } + ); + } else { + console.log(`WARNING: Could not locate sub-table for output ${pk}`); + } + }); + + // Callack for the "unallocate" button + $(table).find('.button-output-unallocate').click(function() { + var pk = $(this).attr('pk'); + + unallocateStock(build_info.pk, { + output: pk, + table: table + }); + }); + + // Callback for the "complete" button + $(table).find('.button-output-complete').click(function() { + var pk = $(this).attr('pk'); + + var output = $(table).bootstrapTable('getRowByUniqueId', pk); + + completeBuildOutputs( + build_info.pk, + [ + output, + ], + { + success: function() { + $(table).bootstrapTable('refresh'); + } + } + ); + }); + + // Callback for the "delete" button + $(table).find('.button-output-delete').click(function() { + var pk = $(this).attr('pk'); + + // TODO: Move this to the API + launchModalForm( + `/build/${build_info.pk}/delete-output/`, + { + data: { + output: pk + }, + onSuccess: function() { + $(table).bootstrapTable('refresh'); + } + } + ); + }); + } + /* - * Load the "allocation table" for a particular build output. - * - * Args: - * - buildId: The PK of the Build object - * - partId: The PK of the Part object - * - output: The StockItem object which is the "output" of the build - * - options: - * -- table: The #id of the table (will be auto-calculated if not provided) + * Construct a "sub table" showing the required BOM items */ + function constructBuildOutputSubTable(index, row, element) { + var sub_table_id = `output-sub-table-${row.pk}`; + + var html = ` +
    +
    +
    + `; + + element.html(html); + + loadBuildOutputAllocationTable( + build_info, + row, + { + table: `#${sub_table_id}`, + parent_table: table, + } + ); + } + + $(table).inventreeTable({ + url: '{% url "api-stock-list" %}', + queryParams: filters, + original: params, + showColumns: false, + uniqueId: 'pk', + name: 'build-outputs', + sortable: true, + search: false, + sidePagination: 'server', + detailView: has_tracked_items, + detailFilter: function(index, row) { + return true; + }, + detailFormatter: function(index, row, element) { + constructBuildOutputSubTable(index, row, element); + }, + formatNoMatches: function() { + return '{% trans "No active build outputs found" %}'; + }, + onPostBody: function() { + // Add callbacks for the buttons + setupBuildOutputButtonCallbacks(); + + $(table).bootstrapTable('expandAllRows'); + }, + columns: [ + { + title: '', + visible: true, + checkbox: true, + switchable: false, + }, + { + field: 'part', + title: '{% trans "Part" %}', + formatter: function(value, row) { + var thumb = row.part_detail.thumbnail; + + return imageHoverIcon(thumb) + row.part_detail.full_name + makePartIcons(row.part_detail); + } + }, + { + field: 'quantity', + title: '{% trans "Quantity" %}', + formatter: function(value, row) { + + var url = `/stock/item/${row.pk}/`; + + var text = ''; + + if (row.serial && row.quantity == 1) { + text = `{% trans "Serial Number" %}: ${row.serial}`; + } else { + text = `{% trans "Quantity" %}: ${row.quantity}`; + } + + return renderLink(text, url); + } + }, + { + field: 'allocated', + title: '{% trans "Allocated Parts" %}', + visible: has_tracked_items, + formatter: function(value, row) { + return `
    `; + } + }, + { + field: 'actions', + title: '', + switchable: false, + formatter: function(value, row) { + return makeBuildOutputButtons( + row.pk, + build_info, + ); + } + } + ] + }); + + // Enable the "allocate" button when the sub-table is exanded + $(table).on('expand-row.bs.table', function(detail, index, row) { + $(`#button-output-allocate-${row.pk}`).prop('disabled', false); + }); + + // Disable the "allocate" button when the sub-table is collapsed + $(table).on('collapse-row.bs.table', function(detail, index, row) { + $(`#button-output-allocate-${row.pk}`).prop('disabled', true); + }); +} + + +/* + * Display the "allocation table" for a particular build output. + * + * This displays a table of required allocations for a particular build output + * + * Args: + * - buildId: The PK of the Build object + * - partId: The PK of the Part object + * - output: The StockItem object which is the "output" of the build + * - options: + * -- table: The #id of the table (will be auto-calculated if not provided) + */ +function loadBuildOutputAllocationTable(buildInfo, output, options={}) { + var buildId = buildInfo.pk; var partId = buildInfo.part; @@ -534,7 +849,11 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { }, name: 'build-allocation', uniqueId: 'sub_part', - onPostBody: setupCallbacks, + search: options.search || false, + onPostBody: function(data) { + // Setup button callbacks + setupCallbacks(); + }, onLoadSuccess: function(tableData) { // Once the BOM data are loaded, request allocation data for this build output @@ -610,31 +929,31 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { $(table).bootstrapTable('updateByUniqueId', key, tableRow, true); } - // Update the total progress for this build output - var buildProgress = $(`#allocation-panel-${outputId}`).find($(`#output-progress-${outputId}`)); + // Update the progress bar for this build output + var build_progress = $(`#output-progress-${outputId}`); - if (totalLines > 0) { + if (build_progress.exists()) { + if (totalLines > 0) { - var progress = makeProgressBar( - allocatedLines, - totalLines - ); - - buildProgress.html(progress); + var progress = makeProgressBar( + allocatedLines, + totalLines + ); + + build_progress.html(progress); + } else { + build_progress.html(''); + } + } else { - buildProgress.html(''); + console.log(`WARNING: Could not find progress bar for output ${outputId}`); } - - // Update the available actions for this build output - - makeBuildOutputActionButtons(output, buildInfo, totalLines); } } ); }, sortable: true, showColumns: false, - detailViewByClick: true, detailView: true, detailFilter: function(index, row) { return row.allocations != null; @@ -883,9 +1202,6 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { }, ] }); - - // Initialize the action buttons - makeBuildOutputActionButtons(output, buildInfo, 0); } @@ -995,10 +1311,13 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { remaining = 0; } - table_entries += renderBomItemRow(bom_item, remaining); + // We only care about entries which are not yet fully allocated + if (remaining > 0) { + table_entries += renderBomItemRow(bom_item, remaining); + } } - if (bom_items.length == 0) { + if (table_entries.length == 0) { showAlertDialog( '{% trans "Select Parts" %}', @@ -1085,6 +1404,24 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { render_part_detail: true, render_location_detail: true, auto_fill: true, + onSelect: function(data, field, opts) { + // Adjust the 'quantity' field based on availability + + if (!('quantity' in data)) { + return; + } + + // Quantity remaining to be allocated + var remaining = Math.max((bom_item.required || 0) - (bom_item.allocated || 0), 0); + + // Calculate the available quantity + var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0); + + // Maximum amount that we need + var desired = Math.min(available, remaining); + + updateFieldValue(`items_quantity_${bom_item.pk}`, desired, {}, opts); + }, adjustFilters: function(filters) { // Restrict query to the selected location var location = getFormFieldValue( @@ -1198,9 +1535,10 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { } - +/* + * Display a table of Build orders + */ function loadBuildTable(table, options) { - // Display a table of Build objects var params = options.params || {}; @@ -1467,190 +1805,4 @@ function loadAllocationTable(table, part_id, part, url, required, button) { } }); }); - -} - - -function loadBuildPartsTable(table, options={}) { - /** - * Display a "required parts" table for build view. - * - * This is a simplified BOM view: - * - Does not display sub-bom items - * - Does not allow editing of BOM items - * - * Options: - * - * part: Part ID - * build: Build ID - * build_quantity: Total build quantity - * build_remaining: Number of items remaining - */ - - // Query params - var params = { - sub_part_detail: true, - part: options.part, - }; - - var filters = {}; - - if (!options.disableFilters) { - filters = loadTableFilters('bom'); - } - - setupFilterList('bom', $(table)); - - for (var key in params) { - filters[key] = params[key]; - } - - function setupTableCallbacks() { - // Register button callbacks once the table data are loaded - - // Callback for 'buy' button - $(table).find('.button-buy').click(function() { - var pk = $(this).attr('pk'); - - launchModalForm('{% url "order-parts" %}', { - data: { - parts: [ - pk, - ] - } - }); - }); - - // Callback for 'build' button - $(table).find('.button-build').click(function() { - var pk = $(this).attr('pk'); - - newBuildOrder({ - part: pk, - parent: options.build, - }); - }); - } - - var columns = [ - { - field: 'sub_part', - title: '{% trans "Part" %}', - switchable: false, - sortable: true, - formatter: function(value, row) { - var url = `/part/${row.sub_part}/`; - var html = imageHoverIcon(row.sub_part_detail.thumbnail) + renderLink(row.sub_part_detail.full_name, url); - - var sub_part = row.sub_part_detail; - - html += makePartIcons(row.sub_part_detail); - - // Display an extra icon if this part is an assembly - if (sub_part.assembly) { - var text = ``; - - html += renderLink(text, `/part/${row.sub_part}/bom/`); - } - - return html; - } - }, - { - field: 'sub_part_detail.description', - title: '{% trans "Description" %}', - }, - { - field: 'reference', - title: '{% trans "Reference" %}', - searchable: true, - sortable: true, - }, - { - field: 'quantity', - title: '{% trans "Quantity" %}', - sortable: true - }, - { - sortable: true, - switchable: false, - field: 'sub_part_detail.stock', - title: '{% trans "Available" %}', - formatter: function(value, row) { - return makeProgressBar( - value, - row.quantity * options.build_remaining, - { - id: `part-progress-${row.part}` - } - ); - }, - sorter: function(valA, valB, rowA, rowB) { - if (rowA.received == 0 && rowB.received == 0) { - return (rowA.quantity > rowB.quantity) ? 1 : -1; - } - - var progressA = parseFloat(rowA.sub_part_detail.stock) / (rowA.quantity * options.build_remaining); - var progressB = parseFloat(rowB.sub_part_detail.stock) / (rowB.quantity * options.build_remaining); - - return (progressA < progressB) ? 1 : -1; - } - }, - { - field: 'actions', - title: '{% trans "Actions" %}', - switchable: false, - formatter: function(value, row) { - - // Generate action buttons against the part - var html = `
    `; - - if (row.sub_part_detail.assembly) { - html += makeIconButton('fa-tools icon-blue', 'button-build', row.sub_part, '{% trans "Build stock" %}'); - } - - if (row.sub_part_detail.purchaseable) { - html += makeIconButton('fa-shopping-cart icon-blue', 'button-buy', row.sub_part, '{% trans "Order stock" %}'); - } - - html += `
    `; - - return html; - } - } - ]; - - table.inventreeTable({ - url: '{% url "api-bom-list" %}', - showColumns: true, - name: 'build-parts', - sortable: true, - search: true, - onPostBody: setupTableCallbacks, - rowStyle: function(row) { - var classes = []; - - // Shade rows differently if they are for different parent parts - if (row.part != options.part) { - classes.push('rowinherited'); - } - - if (row.validated) { - classes.push('rowvalid'); - } else { - classes.push('rowinvalid'); - } - - return { - classes: classes.join(' '), - }; - }, - formatNoMatches: function() { - return '{% trans "No BOM items found" %}'; - }, - clickToSelect: true, - queryParams: filters, - original: params, - columns: columns, - }); } diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 2483263219..db2c8e46cc 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -1426,6 +1426,11 @@ function initializeRelatedField(field, fields, options) { data = item.element.instance; } + // Run optional callback function + if (field.onSelect && data) { + field.onSelect(data, field, options); + } + if (!data.pk) { return field.placeholder || ''; } @@ -1843,6 +1848,8 @@ function constructInput(name, parameters, options) { case 'candy': func = constructCandyInput; break; + case 'raw': + func = constructRawInput; default: // Unsupported field type! break; @@ -2086,6 +2093,17 @@ function constructCandyInput(name, parameters) { } +/* + * Construct a "raw" field input + * No actual field data! + */ +function constructRawInput(name, parameters) { + + return parameters.html; + +} + + /* * Construct a 'help text' div based on the field parameters * diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index 164452952d..1bc15ea402 100644 --- a/InvenTree/templates/js/translated/helpers.js +++ b/InvenTree/templates/js/translated/helpers.js @@ -87,8 +87,10 @@ function select2Thumbnail(image) { } +/* + * Construct an 'icon badge' which floats to the right of an object + */ function makeIconBadge(icon, title) { - // Construct an 'icon badge' which floats to the right of an object var html = ``; @@ -96,8 +98,10 @@ function makeIconBadge(icon, title) { } +/* + * Construct an 'icon button' using the fontawesome set + */ function makeIconButton(icon, cls, pk, title, options={}) { - // Construct an 'icon button' using the fontawesome set var classes = `btn btn-default btn-glyph ${cls}`; diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index 0bb0818a70..bf3628d656 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -168,11 +168,7 @@ function renderPart(name, data, parameters, options) { // Display available part quantity if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) { - if (data.in_stock == 0) { - extra += `{% trans "No Stock" %}`; - } else { - extra += `{% trans "Stock" %}: ${data.in_stock}`; - } + extra += partStockLabel(data); } if (!data.active) { diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 7cadfe453d..67fef0b853 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -1641,6 +1641,13 @@ function loadSalesOrderLineItemTable(table, options={}) { var line_item = $(table).bootstrapTable('getRowByUniqueId', pk); + // Quantity remaining to be allocated + var remaining = (line_item.quantity || 0) - (line_item.allocated || 0); + + if (remaining < 0) { + remaining = 0; + } + var fields = { // SalesOrderLineItem reference line: { @@ -1654,9 +1661,26 @@ function loadSalesOrderLineItemTable(table, options={}) { in_stock: true, part: line_item.part, exclude_so_allocation: options.order, - } + }, + auto_fill: true, + onSelect: function(data, field, opts) { + // Quantity available from this stock item + + if (!('quantity' in data)) { + return; + } + + // Calculate the available quantity + var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0); + + // Maximum amount that we need + var desired = Math.min(available, remaining); + + updateFieldValue('quantity', desired, {}, opts); + } }, quantity: { + value: remaining, }, }; @@ -1752,7 +1776,7 @@ function loadSalesOrderLineItemTable(table, options={}) { showFooter: true, uniqueId: 'pk', detailView: show_detail, - detailViewByClick: show_detail, + detailViewByClick: false, detailFilter: function(index, row) { if (pending) { // Order is pending diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index d7747a244f..0de423b489 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -35,6 +35,7 @@ loadSellPricingChart, loadSimplePartTable, loadStockPricingChart, + partStockLabel, toggleStar, */ @@ -409,6 +410,18 @@ function toggleStar(options) { } +function partStockLabel(part, options={}) { + + var label_class = options.label_class || 'label-form'; + + if (part.in_stock) { + return `{% trans "Stock" %}: ${part.in_stock}`; + } else { + return `{% trans "No Stock" %}`; + } +} + + function makePartIcons(part) { /* Render a set of icons for the given part. */ @@ -778,7 +791,7 @@ function partGridTile(part) { var html = ` -
    +
    `; } diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js index 3138c1e73d..fed7a5d980 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -56,10 +56,10 @@ function enableButtons(elements, enabled) { } +/* Link a bootstrap-table object to one or more buttons. + * The buttons will only be enabled if there is at least one row selected + */ function linkButtonsToSelection(table, buttons) { - /* Link a bootstrap-table object to one or more buttons. - * The buttons will only be enabled if there is at least one row selected - */ if (typeof table === 'string') { table = $(table); diff --git a/InvenTree/templates/required_part_table.html b/InvenTree/templates/required_part_table.html deleted file mode 100644 index a1e26e2894..0000000000 --- a/InvenTree/templates/required_part_table.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - {% for part in parts %} - - - - - - - {{ part.net_stock }} - - {% endfor %} -
    PartDescriptionIn StockOn OrderAlloctedNet Stock
    - {% include "hover_image.html" with image=part.image hover=True %} - {{ part.full_name }} - {{ part.description }}{{ part.total_stock }}{{ part.on_order }}{{ part.allocation_count }}
    \ No newline at end of file diff --git a/InvenTree/templates/slide.html b/InvenTree/templates/slide.html deleted file mode 100644 index edd39e75a2..0000000000 --- a/InvenTree/templates/slide.html +++ /dev/null @@ -1,3 +0,0 @@ -
    - -
    \ No newline at end of file