diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..5ebf729c54 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +patreon: inventree +ko_fi: inventree diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index feaf1558d1..09de857b68 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,14 +12,17 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 25 +INVENTREE_API_VERSION = 26 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about -v25 -> 2022-02-16 +v26 -> 2022-02-17 - Adds API endpoint for uploading a BOM file and extracting data +v25 -> 2022-02-17 + - Adds ability to filter "part" list endpoint by "in_bom_for" argument + v24 -> 2022-02-10 - Adds API endpoint for deleting (cancelling) build order outputs diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 0b0858cb8a..c7577fa68c 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -208,7 +208,7 @@ class BuildOutputCreateSerializer(serializers.Serializer): raise ValidationError(_("Integer quantity required for trackable parts")) if part.has_trackable_parts(): - raise ValidationError(_("Integer quantity required, as the bill of materials contains tracakble parts")) + raise ValidationError(_("Integer quantity required, as the bill of materials contains trackable parts")) return quantity diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 7f19a38183..954060c456 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -995,6 +995,23 @@ class PartList(generics.ListCreateAPIView): except (ValueError, Part.DoesNotExist): pass + # Filter only parts which are in the "BOM" for a given part + in_bom_for = params.get('in_bom_for', None) + + if in_bom_for is not None: + try: + in_bom_for = Part.objects.get(pk=in_bom_for) + + # Extract a list of parts within the BOM + bom_parts = in_bom_for.get_parts_in_bom() + print("bom_parts:", bom_parts) + print([p.pk for p in bom_parts]) + + queryset = queryset.filter(pk__in=[p.pk for p in bom_parts]) + + except (ValueError, Part.DoesNotExist): + pass + # Filter by whether the BOM has been validated (or not) bom_valid = params.get('bom_valid', None) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 478c4c195c..33ad8bf612 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -483,6 +483,36 @@ class Part(MPTTModel): def __str__(self): return f"{self.full_name} - {self.description}" + def get_parts_in_bom(self): + """ + Return a list of all parts in the BOM for this part. + Takes into account substitutes, variant parts, and inherited BOM items + """ + + parts = set() + + for bom_item in self.get_bom_items(): + for part in bom_item.get_valid_parts_for_allocation(): + parts.add(part) + + return parts + + def check_if_part_in_bom(self, other_part): + """ + Check if the other_part is in the BOM for this part. + + Note: + - Accounts for substitute parts + - Accounts for variant BOMs + """ + + for bom_item in self.get_bom_items(): + if other_part in bom_item.get_valid_parts_for_allocation(): + return True + + # No matches found + return False + def check_add_to_bom(self, parent, raise_error=False, recursive=True): """ Check if this Part can be added to the BOM of another part. diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index a13c7f37c3..9723e01c09 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -109,6 +109,31 @@ class StockItemSerialize(generics.CreateAPIView): return context +class StockItemInstall(generics.CreateAPIView): + """ + API endpoint for installing a particular stock item into this stock item. + + - stock_item.part must be in the BOM for this part + - stock_item must currently be "in stock" + - stock_item must be serialized (and not belong to another item) + """ + + queryset = StockItem.objects.none() + serializer_class = StockSerializers.InstallStockItemSerializer + + def get_serializer_context(self): + + context = super().get_serializer_context() + context['request'] = self.request + + try: + context['item'] = StockItem.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + return context + + class StockAdjustView(generics.CreateAPIView): """ A generic class for handling stocktake actions. @@ -503,11 +528,34 @@ class StockList(generics.ListCreateAPIView): serial_numbers = data.get('serial_numbers', '') # Assign serial numbers for a trackable part - if serial_numbers and part.trackable: + if serial_numbers: + + if not part.trackable: + raise ValidationError({ + 'serial_numbers': [_("Serial numbers cannot be supplied for a non-trackable part")] + }) # If serial numbers are specified, check that they match! try: serials = extract_serial_numbers(serial_numbers, quantity, part.getLatestSerialNumberInt()) + + # Determine if any of the specified serial numbers already exist! + existing = [] + + for serial in serials: + if part.checkIfSerialNumberExists(serial): + existing.append(serial) + + if len(existing) > 0: + + msg = _("The following serial numbers already exist") + msg += " : " + msg += ",".join([str(e) for e in existing]) + + raise ValidationError({ + 'serial_numbers': [msg], + }) + except DjangoValidationError as e: raise ValidationError({ 'quantity': e.messages, @@ -1256,6 +1304,7 @@ stock_api_urls = [ # Detail views for a single stock item url(r'^(?P\d+)/', include([ url(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'), + url(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'), url(r'^.*$', StockDetail.as_view(), name='api-stock-detail'), ])), diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index dcbf722997..ef65b25cd9 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -8,7 +8,6 @@ from __future__ import unicode_literals from django import forms from django.forms.utils import ErrorDict from django.utils.translation import ugettext_lazy as _ -from django.core.exceptions import ValidationError from mptt.fields import TreeNodeChoiceField @@ -16,8 +15,6 @@ from InvenTree.forms import HelperForm from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import DatePickerFormField -from part.models import Part - from .models import StockLocation, StockItem, StockItemTracking @@ -162,56 +159,6 @@ class SerializeStockForm(HelperForm): ] -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( - queryset=Part.objects.all(), - widget=forms.HiddenInput() - ) - - stock_item = forms.ModelChoiceField( - required=True, - queryset=StockItem.objects.filter(StockItem.IN_STOCK_FILTER), - help_text=_('Stock item to install') - ) - - to_install = forms.BooleanField( - widget=forms.HiddenInput(), - required=False, - ) - - notes = forms.CharField( - required=False, - help_text=_('Notes') - ) - - class Meta: - model = StockItem - fields = [ - 'part', - 'stock_item', - # 'quantity_to_install', - 'notes', - ] - - def clean(self): - - data = super().clean() - - stock_item = data.get('stock_item', None) - quantity = data.get('quantity_to_install', None) - - if stock_item and quantity and quantity > stock_item.quantity: - raise ValidationError({'quantity_to_install': _('Must not exceed available quantity')}) - - return data - - class UninstallStockForm(forms.ModelForm): """ Form for uninstalling a stock item which is installed in another item. diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index cdc844095b..941219da6c 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -391,6 +391,63 @@ class SerializeStockItemSerializer(serializers.Serializer): ) +class InstallStockItemSerializer(serializers.Serializer): + """ + Serializer for installing a stock item into a given part + """ + + stock_item = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.all(), + many=False, + required=True, + allow_null=False, + label=_('Stock Item'), + help_text=_('Select stock item to install'), + ) + + note = serializers.CharField( + label=_('Note'), + required=False, + allow_blank=True, + ) + + def validate_stock_item(self, stock_item): + """ + Validate the selected stock item + """ + + if not stock_item.in_stock: + # StockItem must be in stock to be "installed" + raise ValidationError(_("Stock item is unavailable")) + + # Extract the "parent" item - the item into which the stock item will be installed + parent_item = self.context['item'] + parent_part = parent_item.part + + if not parent_part.check_if_part_in_bom(stock_item.part): + raise ValidationError(_("Selected part is not in the Bill of Materials")) + + return stock_item + + def save(self): + """ Install the selected stock item into this one """ + + data = self.validated_data + + stock_item = data['stock_item'] + note = data.get('note', '') + + parent_item = self.context['item'] + request = self.context['request'] + + parent_item.installStockItem( + stock_item, + stock_item.quantity, + request.user, + note, + ) + + class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer): """ Serializer for a simple tree view diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index 5c3f94f72f..f42a768069 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -183,16 +183,11 @@ $('#stock-item-install').click(function() { - launchModalForm( - "{% url 'stock-item-install' item.pk %}", - { - data: { - 'part': {{ item.part.pk }}, - 'install_item': true, - }, - reload: true, + installStockItem({{ item.pk }}, {{ item.part.pk }}, { + onSuccess: function(response) { + $("#installed-table").bootstrapTable('refresh'); } - ); + }); }); loadInstalledInTable( @@ -311,65 +306,6 @@ }); }); - $("#test-result-table").on('click', '.button-test-add', function() { - var button = $(this); - - var test_name = button.attr('pk'); - - constructForm('{% url "api-stock-test-result-list" %}', { - method: 'POST', - fields: { - test: { - value: test_name, - }, - result: {}, - value: {}, - attachment: {}, - notes: {}, - stock_item: { - value: {{ item.pk }}, - hidden: true, - } - }, - title: '{% trans "Add Test Result" %}', - onSuccess: reloadTable, - }); - }); - - $("#test-result-table").on('click', '.button-test-edit', function() { - var button = $(this); - - var pk = button.attr('pk'); - - var url = `/api/stock/test/${pk}/`; - - constructForm(url, { - fields: { - test: {}, - result: {}, - value: {}, - attachment: {}, - notes: {}, - }, - title: '{% trans "Edit Test Result" %}', - onSuccess: reloadTable, - }); - }); - - $("#test-result-table").on('click', '.button-test-delete', function() { - var button = $(this); - - var pk = button.attr('pk'); - - var url = `/api/stock/test/${pk}/`; - - constructForm(url, { - method: 'DELETE', - title: '{% trans "Delete Test Result" %}', - onSuccess: reloadTable, - }); - }); - {% if item.child_count > 0 %} loadStockTable($("#childs-stock-table"), { params: { diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index cb4ecc3059..7692d632f0 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -98,7 +98,9 @@
  • {% trans "Uninstall" %}
  • {% else %} {% if item.part.get_used_in %} -
  • {% trans "Install" %}
  • + {% endif %} {% endif %} @@ -442,16 +444,7 @@ $("#stock-serialize").click(function() { $('#stock-install-in').click(function() { - launchModalForm( - "{% url 'stock-item-install' item.pk %}", - { - data: { - 'part': {{ item.part.pk }}, - 'install_in': true, - }, - reload: true, - } - ); + // TODO - Launch dialog to install this item *into* another stock item }); $('#stock-uninstall').click(function() { @@ -618,7 +611,7 @@ enableBreadcrumbTree({ {% endif %} processNode: function(node) { node.text = node.name; - node.href = `/stock/item/${node.pk}/`; + node.href = `/stock/location/${node.pk}/`; return node; } diff --git a/InvenTree/stock/templates/stock/item_install.html b/InvenTree/stock/templates/stock/item_install.html deleted file mode 100644 index 8a94f304d3..0000000000 --- a/InvenTree/stock/templates/stock/item_install.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} - -{% if install_item %} -

    - {% trans "Install another Stock Item into this item." %} -

    -

    - {% trans "Stock items can only be installed if they meet the following criteria" %}: - -

    -

    -{% elif install_in %} -

    - {% trans "Install this Stock Item in another stock item." %} -

    -

    - {% trans "Stock items can only be installed if they meet the following criteria" %}: - -

    -

    -{% endif %} - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 7f35904b51..b2536e0b97 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -24,7 +24,6 @@ stock_item_detail_urls = [ url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'), url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'), url(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'), - url(r'^install/', views.StockItemInstall.as_view(), name='stock-item-install'), url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 6c89db0f2f..9aa70255b1 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -465,155 +465,6 @@ class StockItemQRCode(QRCodeView): return None -class StockItemInstall(AjaxUpdateView): - """ - View for manually installing stock items into - a particular stock item. - - In contrast to the StockItemUninstall view, - only a single stock item can be installed at once. - - The "part" to be installed must be provided in the GET query parameters. - - """ - - model = StockItem - form_class = StockForms.InstallStockForm - ajax_form_title = _('Install Stock Item') - ajax_template_name = "stock/item_install.html" - - part = None - - def get_params(self): - """ Retrieve GET parameters """ - - # Look at GET params - self.part_id = self.request.GET.get('part', None) - self.install_in = self.request.GET.get('install_in', False) - self.install_item = self.request.GET.get('install_item', False) - - if self.part_id is None: - # Look at POST params - self.part_id = self.request.POST.get('part', None) - - try: - self.part = Part.objects.get(pk=self.part_id) - except (ValueError, Part.DoesNotExist): - self.part = None - - def get_stock_items(self): - """ - Return a list of stock items suitable for displaying to the user. - - Requirements: - - Items must be in stock - - Items must be in BOM of stock item - - Items must be serialized - """ - - # Filter items in stock - items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER) - - # Filter serialized stock items - items = items.exclude(serial__isnull=True).exclude(serial__exact='') - - if self.part: - # Filter for parts to install this item in - if self.install_in: - # Get parts using this part - allowed_parts = self.part.get_used_in() - # Filter - items = items.filter(part__in=allowed_parts) - - # Filter for parts to install in this item - if self.install_item: - # Get all parts which can be installed into this part - allowed_parts = self.part.get_installed_part_options() - # Filter - items = items.filter(part__in=allowed_parts) - - return items - - def get_context_data(self, **kwargs): - """ Retrieve parameters and update context """ - - ctx = super().get_context_data(**kwargs) - - # Get request parameters - self.get_params() - - ctx.update({ - 'part': self.part, - 'install_in': self.install_in, - 'install_item': self.install_item, - }) - - return ctx - - def get_initial(self): - - initials = super().get_initial() - - items = self.get_stock_items() - - # If there is a single stock item available, we can use it! - if items.count() == 1: - item = items.first() - initials['stock_item'] = item.pk - - if self.part: - initials['part'] = self.part - - try: - # Is this stock item being installed in the other stock item? - initials['to_install'] = self.install_in or not self.install_item - except AttributeError: - pass - - return initials - - def get_form(self): - - form = super().get_form() - - form.fields['stock_item'].queryset = self.get_stock_items() - - return form - - def post(self, request, *args, **kwargs): - - self.get_params() - - form = self.get_form() - - valid = form.is_valid() - - if valid: - # We assume by this point that we have a valid stock_item and quantity values - data = form.cleaned_data - - other_stock_item = data['stock_item'] - # Quantity will always be 1 for serialized item - quantity = 1 - notes = data['notes'] - - # Get stock item - this_stock_item = self.get_object() - - if data['to_install']: - # Install this stock item into the other stock item - other_stock_item.installStockItem(this_stock_item, quantity, request.user, notes) - else: - # Install the other stock item into this one - this_stock_item.installStockItem(other_stock_item, quantity, request.user, notes) - - data = { - 'form_valid': valid, - } - - return self.renderJsonResponse(request, form, data=data) - - class StockItemUninstall(AjaxView, FormMixin): """ View for uninstalling one or more StockItems, diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 809cd6fa8c..10b1b71073 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -46,6 +46,7 @@ editStockLocation, exportStock, findStockItemBySerialNumber, + installStockItem, loadInstalledInTable, loadStockAllocationTable, loadStockLocationTable, @@ -1227,14 +1228,42 @@ function formatDate(row) { return html; } +/* + * Load StockItemTestResult table + */ function loadStockTestResultsTable(table, options) { - /* - * Load StockItemTestResult table - */ + + // Setup filters for the table + var filterTarget = options.filterTarget || '#filter-list-stocktests'; + + var filterKey = options.filterKey || options.name || 'stocktests'; + + var filters = loadTableFilters(filterKey); + + var params = { + part: options.part, + }; + + var original = {}; + + for (var k in params) { + original[k] = params[k]; + filters[k] = params[k]; + } + + setupFilterList(filterKey, table, filterTarget); function makeButtons(row, grouped) { + + // Helper function for rendering buttons + var html = `
    `; + if (row.requires_attachment == false && row.requires_value == false && !row.result) { + // Enable a "quick tick" option for this test result + html += makeIconButton('fa-check-circle icon-green', 'button-test-tick', row.test_name, '{% trans "Pass test" %}'); + } + html += makeIconButton('fa-plus icon-green', 'button-test-add', row.test_name, '{% trans "Add test result" %}'); if (!grouped && row.result != null) { @@ -1258,14 +1287,13 @@ function loadStockTestResultsTable(table, options) { rootParentId: parent_node, parentIdField: 'parent', idField: 'pk', - uniqueId: 'key', + uniqueId: 'pk', treeShowField: 'test_name', formatNoMatches: function() { return '{% trans "No test results found" %}'; }, - queryParams: { - part: options.part, - }, + queryParams: filters, + original: original, onPostBody: function() { table.treegrid({ treeColumn: 0, @@ -1401,6 +1429,102 @@ function loadStockTestResultsTable(table, options) { ); } }); + + /* Register button callbacks */ + + function reloadTestTable(response) { + $(table).bootstrapTable('refresh'); + } + + // "tick" a test result + $(table).on('click', '.button-test-tick', function() { + var button = $(this); + + var test_name = button.attr('pk'); + + inventreePut( + '{% url "api-stock-test-result-list" %}', + { + test: test_name, + result: true, + stock_item: options.stock_item, + }, + { + method: 'POST', + success: reloadTestTable, + } + ); + }); + + // Add a test result + $(table).on('click', '.button-test-add', function() { + var button = $(this); + + var test_name = button.attr('pk'); + + constructForm('{% url "api-stock-test-result-list" %}', { + method: 'POST', + fields: { + test: { + value: test_name, + }, + result: {}, + value: {}, + attachment: {}, + notes: {}, + stock_item: { + value: options.stock_item, + hidden: true, + } + }, + title: '{% trans "Add Test Result" %}', + onSuccess: reloadTestTable, + }); + }); + + // Edit a test result + $(table).on('click', '.button-test-edit', function() { + var button = $(this); + + var pk = button.attr('pk'); + + var url = `/api/stock/test/${pk}/`; + + constructForm(url, { + fields: { + test: {}, + result: {}, + value: {}, + attachment: {}, + notes: {}, + }, + title: '{% trans "Edit Test Result" %}', + onSuccess: reloadTestTable, + }); + }); + + // Delete a test result + $(table).on('click', '.button-test-delete', function() { + var button = $(this); + + var pk = button.attr('pk'); + + var url = `/api/stock/test/${pk}/`; + + var row = $(table).bootstrapTable('getRowByUniqueId', pk); + + var html = ` +
    + {% trans "Delete test result" %}: ${row.test_name || row.test || row.key} +
    `; + + constructForm(url, { + method: 'DELETE', + title: '{% trans "Delete Test Result" %}', + onSuccess: reloadTestTable, + preFormContent: html, + }); + }); } @@ -2837,3 +2961,67 @@ function loadInstalledInTable(table, options) { } }); } + + +/* + * Launch a dialog to install a stock item into another stock item + */ +function installStockItem(stock_item_id, part_id, options={}) { + + var html = ` +
    + {% trans "Install another stock item into this item" %}
    + {% trans "Stock items can only be installed if they meet the following criteria" %}:
    +
      +
    • {% trans "The Stock Item links to a Part which is the BOM for this Stock Item" %}
    • +
    • {% trans "The Stock Item is currently available in stock" %}
    • +
    • {% trans "The Stock Item is serialized and does not belong to another item" %}
    • +
    +
    `; + + constructForm( + `/api/stock/${stock_item_id}/install/`, + { + method: 'POST', + fields: { + part: { + type: 'related field', + required: 'true', + label: '{% trans "Part" %}', + help_text: '{% trans "Select part to install" %}', + model: 'part', + api_url: '{% url "api-part-list" %}', + auto_fill: true, + filters: { + trackable: true, + in_bom_for: part_id, + } + }, + stock_item: { + filters: { + part_detail: true, + in_stock: true, + serialized: true, + }, + adjustFilters: function(filters, opts) { + var part = getFormFieldValue('part', {}, opts); + + if (part) { + filters.part = part; + } + + return filters; + } + } + }, + confirm: true, + title: '{% trans "Install Stock Item" %}', + preFormContent: html, + onSuccess: function(response) { + if (options.onSuccess) { + options.onSuccess(response); + } + } + } + ); +} diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index d9f2014c14..a4c6a0bbac 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -265,12 +265,7 @@ function getAvailableTableFilters(tableKey) { // Filters for the 'stock test' table if (tableKey == 'stocktests') { - return { - result: { - type: 'bool', - title: '{% trans "Test result" %}', - }, - }; + return {}; } // Filters for the 'part test template' table