diff --git a/InvenTree/InvenTree/validators.py b/InvenTree/InvenTree/validators.py index 76d485cef9..6bb2c1b350 100644 --- a/InvenTree/InvenTree/validators.py +++ b/InvenTree/InvenTree/validators.py @@ -2,6 +2,8 @@ Custom field validators for InvenTree """ +from decimal import Decimal, InvalidOperation + from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ @@ -115,26 +117,28 @@ def validate_tree_name(value): def validate_overage(value): - """ Validate that a BOM overage string is properly formatted. + """ + Validate that a BOM overage string is properly formatted. An overage string can look like: - An integer number ('1' / 3 / 4) + - A decimal number ('0.123') - A percentage ('5%' / '10 %') """ value = str(value).lower().strip() - # First look for a simple integer value + # First look for a simple numerical value try: - i = int(value) + i = Decimal(value) if i < 0: raise ValidationError(_("Overage value must not be negative")) - # Looks like an integer! + # Looks like a number return True - except ValueError: + except (ValueError, InvalidOperation): pass # Now look for a percentage value @@ -155,7 +159,7 @@ def validate_overage(value): pass raise ValidationError( - _("Overage must be an integer value or a percentage") + _("Invalid value for overage") ) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index d146c3d7b3..4c52b87520 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -1533,6 +1533,40 @@ class BomList(generics.ListCreateAPIView): ] +class BomExtract(generics.CreateAPIView): + """ + API endpoint for extracting BOM data from a BOM file. + """ + + queryset = Part.objects.none() + serializer_class = part_serializers.BomExtractSerializer + + def create(self, request, *args, **kwargs): + """ + Custom create function to return the extracted data + """ + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + + data = serializer.extract_data() + + return Response(data, status=status.HTTP_201_CREATED, headers=headers) + + +class BomUpload(generics.CreateAPIView): + """ + API endpoint for uploading a complete Bill of Materials. + + It is assumed that the BOM has been extracted from a file using the BomExtract endpoint. + """ + + queryset = Part.objects.all() + serializer_class = part_serializers.BomUploadSerializer + + class BomDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a single BomItem object """ @@ -1685,6 +1719,10 @@ bom_api_urls = [ url(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'), ])), + url(r'^extract/', BomExtract.as_view(), name='api-bom-extract'), + + url(r'^upload/', BomUpload.as_view(), name='api-bom-upload'), + # Catch-all url(r'^.*$', BomList.as_view(), name='api-bom-list'), ] diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 16ecc8da21..351348c6bc 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -4,9 +4,11 @@ JSON serializers for Part app import imghdr from decimal import Decimal +import os +import tablib from django.urls import reverse_lazy -from django.db import models +from django.db import models, transaction from django.db.models import Q from django.db.models.functions import Coalesce from django.utils.translation import ugettext_lazy as _ @@ -462,7 +464,13 @@ class BomItemSerializer(InvenTreeModelSerializer): price_range = serializers.CharField(read_only=True) - quantity = InvenTreeDecimalField() + quantity = InvenTreeDecimalField(required=True) + + def validate_quantity(self, quantity): + if quantity <= 0: + raise serializers.ValidationError(_("Quantity must be greater than zero")) + + return quantity part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True)) @@ -699,3 +707,289 @@ class PartCopyBOMSerializer(serializers.Serializer): skip_invalid=data.get('skip_invalid', False), include_inherited=data.get('include_inherited', False), ) + + +class BomExtractSerializer(serializers.Serializer): + """ + Serializer for uploading a file and extracting data from it. + + Note: 2022-02-04 - This needs a *serious* refactor in future, probably + + When parsing the file, the following things happen: + + a) Check file format and validity + b) Look for "required" fields + c) Look for "part" fields - used to "infer" part + + Once the file itself has been validated, we iterate through each data row: + + - If the "level" column is provided, ignore anything below level 1 + - Try to "guess" the part based on part_id / part_name / part_ipn + - Extract other fields as required + + """ + + class Meta: + fields = [ + 'bom_file', + 'part', + 'clear_existing', + ] + + # These columns must be present + REQUIRED_COLUMNS = [ + 'quantity', + ] + + # We need at least one column to specify a "part" + PART_COLUMNS = [ + 'part', + 'part_id', + 'part_name', + 'part_ipn', + ] + + # These columns are "optional" + OPTIONAL_COLUMNS = [ + 'allow_variants', + 'inherited', + 'optional', + 'overage', + 'note', + 'reference', + ] + + def find_matching_column(self, col_name, columns): + + # Direct match + if col_name in columns: + return col_name + + col_name = col_name.lower().strip() + + for col in columns: + if col.lower().strip() == col_name: + return col + + # No match + return None + + def find_matching_data(self, row, col_name, columns): + """ + Extract data from the row, based on the "expected" column name + """ + + col_name = self.find_matching_column(col_name, columns) + + return row.get(col_name, None) + + bom_file = serializers.FileField( + label=_("BOM File"), + help_text=_("Select Bill of Materials file"), + required=True, + allow_empty_file=False, + ) + + def validate_bom_file(self, bom_file): + """ + Perform validation checks on the uploaded BOM file + """ + + self.filename = bom_file.name + + name, ext = os.path.splitext(bom_file.name) + + # Remove the leading . from the extension + ext = ext[1:] + + accepted_file_types = [ + 'xls', 'xlsx', + 'csv', 'tsv', + 'xml', + ] + + if ext not in accepted_file_types: + raise serializers.ValidationError(_("Unsupported file type")) + + # Impose a 50MB limit on uploaded BOM files + max_upload_file_size = 50 * 1024 * 1024 + + if bom_file.size > max_upload_file_size: + raise serializers.ValidationError(_("File is too large")) + + # Read file data into memory (bytes object) + data = bom_file.read() + + if ext in ['csv', 'tsv', 'xml']: + data = data.decode() + + # Convert to a tablib dataset (we expect headers) + self.dataset = tablib.Dataset().load(data, ext, headers=True) + + for header in self.REQUIRED_COLUMNS: + + match = self.find_matching_column(header, self.dataset.headers) + + if match is None: + raise serializers.ValidationError(_("Missing required column") + f": '{header}'") + + part_column_matches = {} + + part_match = False + + for col in self.PART_COLUMNS: + col_match = self.find_matching_column(col, self.dataset.headers) + + part_column_matches[col] = col_match + + if col_match is not None: + part_match = True + + if not part_match: + raise serializers.ValidationError(_("No part column found")) + + return bom_file + + def extract_data(self): + """ + Read individual rows out of the BOM file + """ + + rows = [] + + headers = self.dataset.headers + + level_column = self.find_matching_column('level', headers) + + for row in self.dataset.dict: + + """ + If the "level" column is specified, and this is not a top-level BOM item, ignore the row! + """ + if level_column is not None: + level = row.get('level', None) + + if level is not None: + try: + level = int(level) + if level != 1: + continue + except: + pass + + """ + Next, we try to "guess" the part, based on the provided data. + + A) If the part_id is supplied, use that! + B) If the part name and/or part_ipn are supplied, maybe we can use those? + """ + part_id = self.find_matching_data(row, 'part_id', headers) + part_name = self.find_matching_data(row, 'part_name', headers) + part_ipn = self.find_matching_data(row, 'part_ipn', headers) + + part = None + + if part_id is not None: + try: + part = Part.objects.get(pk=part_id) + except (ValueError, Part.DoesNotExist): + pass + + # Optionally, specify using field "part" + if part is None: + pk = self.find_matching_data(row, 'part', headers) + + if pk is not None: + try: + part = Part.objects.get(pk=pk) + except (ValueError, Part.DoesNotExist): + pass + + if part is None: + + if part_name is not None or part_ipn is not None: + queryset = Part.objects.all() + + if part_name is not None: + queryset = queryset.filter(name=part_name) + + if part_ipn is not None: + queryset = queryset.filter(IPN=part_ipn) + + # Only if we have a single direct match + if queryset.exists() and queryset.count() == 1: + part = queryset.first() + + row['part'] = part.pk if part is not None else None + + rows.append(row) + + return { + 'rows': rows, + 'headers': headers, + 'filename': self.filename, + } + + part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True), required=True) + + clear_existing = serializers.BooleanField( + label=_("Clear Existing BOM"), + help_text=_("Delete existing BOM data first"), + ) + + def save(self): + + data = self.validated_data + + master_part = data['part'] + clear_existing = data['clear_existing'] + + if clear_existing: + + # Remove all existing BOM items + master_part.bom_items.all().delete() + + +class BomUploadSerializer(serializers.Serializer): + """ + Serializer for uploading a BOM against a specified part. + + A "BOM" is a set of BomItem objects which are to be validated together as a set + """ + + items = BomItemSerializer(many=True, required=True) + + def validate(self, data): + + items = data['items'] + + if len(items) == 0: + raise serializers.ValidationError(_("At least one BOM item is required")) + + data = super().validate(data) + + return data + + def save(self): + + data = self.validated_data + + items = data['items'] + + try: + with transaction.atomic(): + + for item in items: + + part = item['part'] + sub_part = item['sub_part'] + + # Ignore duplicate BOM items + if BomItem.objects.filter(part=part, sub_part=sub_part).exists(): + continue + + # Create a new BomItem object + BomItem.objects.create(**item) + + except Exception as e: + raise serializers.ValidationError(detail=serializers.as_serializer_error(e)) diff --git a/InvenTree/part/templates/part/bom_upload/match_fields.html b/InvenTree/part/templates/part/bom_upload/match_fields.html deleted file mode 100644 index b09260cf46..0000000000 --- a/InvenTree/part/templates/part/bom_upload/match_fields.html +++ /dev/null @@ -1,99 +0,0 @@ -{% extends "part/bom_upload/upload_file.html" %} -{% load inventree_extras %} -{% load i18n %} -{% load static %} - -{% block form_alert %} -{% if missing_columns and missing_columns|length > 0 %} - -{% endif %} -{% if duplicates and duplicates|length > 0 %} - -{% endif %} -{% endblock form_alert %} - -{% block form_buttons_top %} - {% if wizard.steps.prev %} - - {% endif %} - -{% endblock form_buttons_top %} - -{% block form_content %} - - - {% trans "File Fields" %} - - {% for col in form %} - -
- - {{ col.name }} - -
- - {% endfor %} - - - - - {% trans "Match Fields" %} - - {% for col in form %} - - {{ col }} - {% for duplicate in duplicates %} - {% if duplicate == col.value %} - - {% endif %} - {% endfor %} - - {% endfor %} - - {% for row in rows %} - {% with forloop.counter as row_index %} - - - - - {{ row_index }} - {% for item in row.data %} - - - {{ item }} - - {% endfor %} - - {% endwith %} - {% endfor %} - -{% endblock form_content %} - -{% block form_buttons_bottom %} -{% endblock form_buttons_bottom %} - -{% block js_ready %} -{{ block.super }} - -$('.fieldselect').select2({ - width: '100%', - matcher: partialMatcher, -}); - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/match_parts.html b/InvenTree/part/templates/part/bom_upload/match_parts.html deleted file mode 100644 index 0345fa309e..0000000000 --- a/InvenTree/part/templates/part/bom_upload/match_parts.html +++ /dev/null @@ -1,127 +0,0 @@ -{% extends "part/bom_upload/upload_file.html" %} -{% load inventree_extras %} -{% load i18n %} -{% load static %} -{% load crispy_forms_tags %} - -{% block form_alert %} -{% if form.errors %} -{% endif %} -{% if form_errors %} - -{% endif %} -{% endblock form_alert %} - -{% block form_buttons_top %} - {% if wizard.steps.prev %} - - {% endif %} - -{% endblock form_buttons_top %} - -{% block form_content %} - - - - {% trans "Row" %} - {% trans "Select Part" %} - {% trans "Reference" %} - {% trans "Quantity" %} - {% for col in columns %} - {% if col.guess != 'Quantity' %} - - - - {% if col.guess %} - {{ col.guess }} - {% else %} - {{ col.name }} - {% endif %} - - {% endif %} - {% endfor %} - - - - {% comment %} Dummy row for javascript del_row method {% endcomment %} - {% for row in rows %} - - - - - - {% add row.index 1 %} - - - {% for field in form.visible_fields %} - {% if field.name == row.item_select %} - {{ field }} - {% endif %} - {% endfor %} - {% if row.errors.part %} -

{{ row.errors.part }}

- {% endif %} - - - {% for field in form.visible_fields %} - {% if field.name == row.reference %} - {{ field|as_crispy_field }} - {% endif %} - {% endfor %} - {% if row.errors.reference %} -

{{ row.errors.reference }}

- {% endif %} - - - {% for field in form.visible_fields %} - {% if field.name == row.quantity %} - {{ field|as_crispy_field }} - {% endif %} - {% endfor %} - {% if row.errors.quantity %} -

{{ row.errors.quantity }}

- {% endif %} - - {% for item in row.data %} - {% if item.column.guess != 'Quantity' %} - - {% if item.column.guess == 'Overage' %} - {% for field in form.visible_fields %} - {% if field.name == row.overage %} - {{ field|as_crispy_field }} - {% endif %} - {% endfor %} - {% elif item.column.guess == 'Note' %} - {% for field in form.visible_fields %} - {% if field.name == row.note %} - {{ field|as_crispy_field }} - {% endif %} - {% endfor %} - {% else %} - {{ item.cell }} - {% endif %} - - - {% endif %} - {% endfor %} - - {% endfor %} - -{% endblock form_content %} - -{% block form_buttons_bottom %} -{% endblock form_buttons_bottom %} - -{% block js_ready %} -{{ block.super }} - -$('.bomselect').select2({ - dropdownAutoWidth: true, - matcher: partialMatcher, -}); - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/bom_upload/upload_file.html b/InvenTree/part/templates/part/bom_upload/upload_file.html deleted file mode 100644 index 40411f074a..0000000000 --- a/InvenTree/part/templates/part/bom_upload/upload_file.html +++ /dev/null @@ -1,67 +0,0 @@ -{% extends "part/part_base.html" %} -{% load inventree_extras %} -{% load i18n %} -{% load static %} - -{% block sidebar %} -{% url "part-detail" part.id as url %} -{% trans "Return to BOM" as text %} -{% include "sidebar_link.html" with url=url text=text icon="fa-undo" %} -{% endblock %} - -{% block heading %} -{% trans "Upload Bill of Materials" %} -{% endblock %} - -{% block actions %} -{% endblock %} - -{% block page_info %} -
-

{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %} - {% if description %}- {{ description }}{% endif %}

- -
- {% csrf_token %} - {% load crispy_forms_tags %} - - {% block form_buttons_top %} - {% endblock form_buttons_top %} - - {% block form_alert %} -
- {% trans "Requirements for BOM upload" %}: -
    -
  • {% trans "The BOM file must contain the required named columns as provided in the " %} {% trans "BOM Upload Template" %}
  • -
  • {% trans "Each part must already exist in the database" %}
  • -
-
- {% endblock %} - - - {{ wizard.management_form }} - {% block form_content %} - {% crispy wizard.form %} - {% endblock form_content %} -
- - {% block form_buttons_bottom %} - {% if wizard.steps.prev %} - - {% endif %} - -
- {% endblock form_buttons_bottom %} -
-{% endblock page_info %} - -{% block js_ready %} -{{ block.super }} - -enableSidebar('bom-upload'); - -$('#bom-template-download').click(function() { - downloadBomTemplate(); -}); - -{% endblock js_ready %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/upload_bom.html b/InvenTree/part/templates/part/upload_bom.html new file mode 100644 index 0000000000..27f681acae --- /dev/null +++ b/InvenTree/part/templates/part/upload_bom.html @@ -0,0 +1,105 @@ +{% extends "part/part_base.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block sidebar %} +{% url "part-detail" part.id as url %} +{% trans "Return to BOM" as text %} +{% include "sidebar_link.html" with url=url text=text icon="fa-undo" %} +{% endblock %} + +{% block heading %} +{% trans "Upload Bill of Materials" %} +{% endblock %} + +{% block actions %} + + + +{% endblock %} + +{% block page_info %} +
+ +
+ {% trans "Requirements for BOM upload" %}: + +
+ +
+ +
+ + + + + + + + + + + + + + + + + +
{% trans "Part" %}{% trans "Quantity" %}{% trans "Reference" %}{% trans "Overage" %}{% trans "Allow Variants" %}{% trans "Inherited" %}{% trans "Optional" %}{% trans "Note" %}
+ +
+{% endblock page_info %} + +{% block js_ready %} +{{ block.super }} + +enableSidebar('bom-upload'); + +$('#bom-template-download').click(function() { + downloadBomTemplate(); +}); + +$('#bom-upload').click(function() { + + constructForm('{% url "api-bom-extract" %}', { + method: 'POST', + fields: { + bom_file: {}, + part: { + value: {{ part.pk }}, + hidden: true, + }, + clear_existing: {}, + }, + title: '{% trans "Upload BOM File" %}', + onSuccess: function(response) { + $('#bom-upload').hide(); + + $('#bom-submit').show(); + + constructBomUploadTable(response); + + $('#bom-submit').click(function() { + submitBomTable({{ part.pk }}, { + bom_data: response, + }); + }); + } + }); + +}); + +{% endblock js_ready %} \ No newline at end of file diff --git a/InvenTree/part/test_bom_export.py b/InvenTree/part/test_bom_export.py index e6b2a7c255..4ae0b88269 100644 --- a/InvenTree/part/test_bom_export.py +++ b/InvenTree/part/test_bom_export.py @@ -107,7 +107,7 @@ class BomExportTest(TestCase): """ params = { - 'file_format': 'csv', + 'format': 'csv', 'cascade': True, 'parameter_data': True, 'stock_data': True, @@ -171,7 +171,7 @@ class BomExportTest(TestCase): """ params = { - 'file_format': 'xls', + 'format': 'xls', 'cascade': True, 'parameter_data': True, 'stock_data': True, @@ -192,7 +192,7 @@ class BomExportTest(TestCase): """ params = { - 'file_format': 'xlsx', + 'format': 'xlsx', 'cascade': True, 'parameter_data': True, 'stock_data': True, @@ -210,7 +210,7 @@ class BomExportTest(TestCase): """ params = { - 'file_format': 'json', + 'format': 'json', 'cascade': True, 'parameter_data': True, 'stock_data': True, diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 14f3e28b24..ba843f7d4b 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -33,7 +33,6 @@ part_parameter_urls = [ part_detail_urls = [ url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'), - url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'), url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'), url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 97485ebe32..e0992364dd 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -28,20 +28,17 @@ import requests import os import io -from rapidfuzz import fuzz -from decimal import Decimal, InvalidOperation +from decimal import Decimal from .models import PartCategory, Part from .models import PartParameterTemplate from .models import PartCategoryParameterTemplate -from .models import BomItem from .models import PartSellPriceBreak, PartInternalPriceBreak from common.models import InvenTreeSetting from company.models import SupplierPart from common.files import FileManager from common.views import FileManagementFormView, FileManagementAjaxView -from common.forms import UploadFileForm, MatchFieldForm from stock.models import StockItem, StockLocation @@ -704,270 +701,12 @@ class PartImageSelect(AjaxUpdateView): return self.renderJsonResponse(request, form, data) -class BomUpload(InvenTreeRoleMixin, FileManagementFormView): - """ View for uploading a BOM file, and handling BOM data importing. +class BomUpload(InvenTreeRoleMixin, DetailView): + """ View for uploading a BOM file, and handling BOM data importing. """ - The BOM upload process is as follows: - - 1. (Client) Select and upload BOM file - 2. (Server) Verify that supplied file is a file compatible with tablib library - 3. (Server) Introspect data file, try to find sensible columns / values / etc - 4. (Server) Send suggestions back to the client - 5. (Client) Makes choices based on suggestions: - - Accept automatic matching to parts found in database - - Accept suggestions for 'partial' or 'fuzzy' matches - - Create new parts in case of parts not being available - 6. (Client) Sends updated dataset back to server - 7. (Server) Check POST data for validity, sanity checking, etc. - 8. (Server) Respond to POST request - - If data are valid, proceed to 9. - - If data not valid, return to 4. - 9. (Server) Send confirmation form to user - - Display the actions which will occur - - Provide final "CONFIRM" button - 10. (Client) Confirm final changes - 11. (Server) Apply changes to database, update BOM items. - - During these steps, data are passed between the server/client as JSON objects. - """ - - role_required = ('part.change', 'part.add') - - class BomFileManager(FileManager): - # Fields which are absolutely necessary for valid upload - REQUIRED_HEADERS = [ - 'Quantity' - ] - - # Fields which are used for part matching (only one of them is needed) - ITEM_MATCH_HEADERS = [ - 'Part_Name', - 'Part_IPN', - 'Part_ID', - ] - - # Fields which would be helpful but are not required - OPTIONAL_HEADERS = [ - 'Reference', - 'Note', - 'Overage', - ] - - EDITABLE_HEADERS = [ - 'Reference', - 'Note', - 'Overage' - ] - - name = 'order' - form_list = [ - ('upload', UploadFileForm), - ('fields', MatchFieldForm), - ('items', part_forms.BomMatchItemForm), - ] - form_steps_template = [ - 'part/bom_upload/upload_file.html', - 'part/bom_upload/match_fields.html', - 'part/bom_upload/match_parts.html', - ] - form_steps_description = [ - _("Upload File"), - _("Match Fields"), - _("Match Parts"), - ] - form_field_map = { - 'item_select': 'part', - 'quantity': 'quantity', - 'overage': 'overage', - 'reference': 'reference', - 'note': 'note', - } - file_manager_class = BomFileManager - - def get_part(self): - """ Get part or return 404 """ - - return get_object_or_404(Part, pk=self.kwargs['pk']) - - def get_context_data(self, form, **kwargs): - """ Handle context data for order """ - - context = super().get_context_data(form=form, **kwargs) - - part = self.get_part() - - context.update({'part': part}) - - return context - - def get_allowed_parts(self): - """ Return a queryset of parts which are allowed to be added to this BOM. - """ - - return self.get_part().get_allowed_bom_items() - - def get_field_selection(self): - """ Once data columns have been selected, attempt to pre-select the proper data from the database. - This function is called once the field selection has been validated. - The pre-fill data are then passed through to the part selection form. - """ - - self.allowed_items = self.get_allowed_parts() - - # Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database - k_idx = self.get_column_index('Part_ID') - p_idx = self.get_column_index('Part_Name') - i_idx = self.get_column_index('Part_IPN') - - q_idx = self.get_column_index('Quantity') - r_idx = self.get_column_index('Reference') - o_idx = self.get_column_index('Overage') - n_idx = self.get_column_index('Note') - - for row in self.rows: - """ - Iterate through each row in the uploaded data, - and see if we can match the row to a "Part" object in the database. - There are three potential ways to match, based on the uploaded data: - a) Use the PK (primary key) field for the part, uploaded in the "Part_ID" field - b) Use the IPN (internal part number) field for the part, uploaded in the "Part_IPN" field - c) Use the name of the part, uploaded in the "Part_Name" field - Notes: - - If using the Part_ID field, we can do an exact match against the PK field - - If using the Part_IPN field, we can do an exact match against the IPN field - - If using the Part_Name field, we can use fuzzy string matching to match "close" values - We also extract other information from the row, for the other non-matched fields: - - Quantity - - Reference - - Overage - - Note - """ - - # Initially use a quantity of zero - quantity = Decimal(0) - - # Initially we do not have a part to reference - exact_match_part = None - - # A list of potential Part matches - part_options = self.allowed_items - - # Check if there is a column corresponding to "quantity" - if q_idx >= 0: - q_val = row['data'][q_idx]['cell'] - - if q_val: - # Delete commas - q_val = q_val.replace(',', '') - - try: - # Attempt to extract a valid quantity from the field - quantity = Decimal(q_val) - # Store the 'quantity' value - row['quantity'] = quantity - except (ValueError, InvalidOperation): - pass - - # Check if there is a column corresponding to "PK" - if k_idx >= 0: - pk = row['data'][k_idx]['cell'] - - if pk: - try: - # Attempt Part lookup based on PK value - exact_match_part = self.allowed_items.get(pk=pk) - except (ValueError, Part.DoesNotExist): - exact_match_part = None - - # Check if there is a column corresponding to "Part IPN" and no exact match found yet - if i_idx >= 0 and not exact_match_part: - part_ipn = row['data'][i_idx]['cell'] - - if part_ipn: - part_matches = [part for part in self.allowed_items if part.IPN and part_ipn.lower() == str(part.IPN.lower())] - - # Check for single match - if len(part_matches) == 1: - exact_match_part = part_matches[0] - - # Check if there is a column corresponding to "Part Name" and no exact match found yet - if p_idx >= 0 and not exact_match_part: - part_name = row['data'][p_idx]['cell'] - - row['part_name'] = part_name - - matches = [] - - for part in self.allowed_items: - ratio = fuzz.partial_ratio(part.name + part.description, part_name) - matches.append({'part': part, 'match': ratio}) - - # Sort matches by the 'strength' of the match ratio - if len(matches) > 0: - matches = sorted(matches, key=lambda item: item['match'], reverse=True) - - part_options = [m['part'] for m in matches] - - # Supply list of part options for each row, sorted by how closely they match the part name - row['item_options'] = part_options - - # Unless found, the 'item_match' is blank - row['item_match'] = None - - if exact_match_part: - # If there is an exact match based on PK or IPN, use that - row['item_match'] = exact_match_part - - # Check if there is a column corresponding to "Overage" field - if o_idx >= 0: - row['overage'] = row['data'][o_idx]['cell'] - - # Check if there is a column corresponding to "Reference" field - if r_idx >= 0: - row['reference'] = row['data'][r_idx]['cell'] - - # Check if there is a column corresponding to "Note" field - if n_idx >= 0: - row['note'] = row['data'][n_idx]['cell'] - - def done(self, form_list, **kwargs): - """ Once all the data is in, process it to add BomItem instances to the part """ - - self.part = self.get_part() - items = self.get_clean_items() - - # Clear BOM - self.part.clear_bom() - - # Generate new BOM items - for bom_item in items.values(): - try: - part = Part.objects.get(pk=int(bom_item.get('part'))) - except (ValueError, Part.DoesNotExist): - continue - - quantity = bom_item.get('quantity') - overage = bom_item.get('overage', '') - reference = bom_item.get('reference', '') - note = bom_item.get('note', '') - - # Create a new BOM item - item = BomItem( - part=self.part, - sub_part=part, - quantity=quantity, - overage=overage, - reference=reference, - note=note, - ) - - try: - item.save() - except IntegrityError: - # BomItem already exists - pass - - return HttpResponseRedirect(reverse('part-detail', kwargs={'pk': self.kwargs['pk']})) + context_object_name = 'part' + queryset = Part.objects.all() + template_name = 'part/upload_bom.html' class PartExport(AjaxView): @@ -1060,7 +799,7 @@ class BomDownload(AjaxView): part = get_object_or_404(Part, pk=self.kwargs['pk']) - export_format = request.GET.get('file_format', 'csv') + export_format = request.GET.get('format', 'csv') cascade = str2bool(request.GET.get('cascade', False)) @@ -1103,55 +842,6 @@ class BomDownload(AjaxView): } -class BomExport(AjaxView): - """ Provide a simple form to allow the user to select BOM download options. - """ - - model = Part - ajax_form_title = _("Export Bill of Materials") - - role_required = 'part.view' - - def post(self, request, *args, **kwargs): - - # Extract POSTed form data - fmt = request.POST.get('file_format', 'csv').lower() - cascade = str2bool(request.POST.get('cascading', False)) - levels = request.POST.get('levels', None) - parameter_data = str2bool(request.POST.get('parameter_data', False)) - stock_data = str2bool(request.POST.get('stock_data', False)) - supplier_data = str2bool(request.POST.get('supplier_data', False)) - manufacturer_data = str2bool(request.POST.get('manufacturer_data', False)) - - try: - part = Part.objects.get(pk=self.kwargs['pk']) - except: - part = None - - # Format a URL to redirect to - if part: - url = reverse('bom-download', kwargs={'pk': part.pk}) - else: - url = '' - - url += '?file_format=' + fmt - url += '&cascade=' + str(cascade) - url += '¶meter_data=' + str(parameter_data) - url += '&stock_data=' + str(stock_data) - url += '&supplier_data=' + str(supplier_data) - url += '&manufacturer_data=' + str(manufacturer_data) - - if levels: - url += '&levels=' + str(levels) - - data = { - 'form_valid': part is not None, - 'url': url, - } - - return self.renderJsonResponse(request, self.form_class(), data=data) - - class PartDelete(AjaxDeleteView): """ View to delete a Part object """ diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 71c6b0b387..fd23e70ad0 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -15,6 +15,7 @@ */ /* exported + constructBomUploadTable, downloadBomTemplate, exportBom, newPartFromBomWizard, @@ -22,8 +23,175 @@ loadUsedInTable, removeRowFromBomWizard, removeColFromBomWizard, + submitBomTable */ + +/* Construct a table of data extracted from a BOM file. + * This data is used to import a BOM interactively. + */ +function constructBomUploadTable(data, options={}) { + + if (!data.rows) { + // TODO: Error message! + return; + } + + function constructRow(row, idx, fields) { + // Construct an individual row from the provided data + + var field_options = { + hideLabels: true, + hideClearButton: true, + form_classes: 'bom-form-group', + }; + + function constructRowField(field_name) { + + var field = fields[field_name] || null; + + if (!field) { + return `Cannot render field '${field_name}`; + } + + field.value = row[field_name]; + + return constructField(`items_${field_name}_${idx}`, field, field_options); + + } + + // Construct form inputs + var sub_part = constructRowField('sub_part'); + var quantity = constructRowField('quantity'); + var reference = constructRowField('reference'); + var overage = constructRowField('overage'); + var variants = constructRowField('allow_variants'); + var inherited = constructRowField('inherited'); + var optional = constructRowField('optional'); + var note = constructRowField('note'); + + var buttons = `
`; + + // buttons += makeIconButton('fa-file-alt', 'button-row-data', idx, '{% trans "Display row data" %}'); + buttons += makeIconButton('fa-times icon-red', 'button-row-remove', idx, '{% trans "Remove row" %}'); + + buttons += `
`; + + var html = ` + + ${sub_part} + ${quantity} + ${reference} + ${overage} + ${variants} + ${inherited} + ${optional} + ${note} + ${buttons} + `; + + $('#bom-import-table tbody').append(html); + + // Initialize the "part" selector for this row + initializeRelatedField( + { + name: `items_sub_part_${idx}`, + value: row.part, + api_url: '{% url "api-part-list" %}', + filters: { + component: true, + }, + model: 'part', + required: true, + auto_fill: false, + onSelect: function(data, field, opts) { + // TODO? + }, + } + ); + + // Add callback for "remove row" button + $(`#button-row-remove-${idx}`).click(function() { + $(`#items_${idx}`).remove(); + }); + } + + // Request API endpoint options + getApiEndpointOptions('{% url "api-bom-list" %}', function(response) { + + var fields = response.actions.POST; + + data.rows.forEach(function(row, idx) { + constructRow(row, idx, fields); + }); + }); +} + + +/* Extract rows from the BOM upload table, + * and submit data to the server + */ +function submitBomTable(part_id, options={}) { + + // Extract rows from the form + var rows = []; + + var idx_values = []; + + var url = '{% url "api-bom-upload" %}'; + + $('.bom-import-row').each(function() { + var idx = $(this).attr('idx'); + + idx_values.push(idx); + + // Extract each field from the row + rows.push({ + part: part_id, + sub_part: getFormFieldValue(`items_sub_part_${idx}`, {}), + quantity: getFormFieldValue(`items_quantity_${idx}`, {}), + reference: getFormFieldValue(`items_reference_${idx}`, {}), + overage: getFormFieldValue(`items_overage_${idx}`, {}), + allow_variants: getFormFieldValue(`items_allow_variants_${idx}`, {type: 'boolean'}), + inherited: getFormFieldValue(`items_inherited_${idx}`, {type: 'boolean'}), + optional: getFormFieldValue(`items_optional_${idx}`, {type: 'boolean'}), + note: getFormFieldValue(`items_note_${idx}`, {}), + }); + }); + + var data = { + items: rows, + }; + + var options = { + nested: { + items: idx_values, + } + }; + + getApiEndpointOptions(url, function(response) { + var fields = response.actions.POST; + + inventreePut(url, data, { + method: 'POST', + success: function(response) { + window.location.href = `/part/${part_id}/?display=bom`; + }, + error: function(xhr) { + switch (xhr.status) { + case 400: + handleFormErrors(xhr.responseJSON, fields, options); + break; + default: + showApiError(xhr, url); + break; + } + } + }); + }); +} + + function downloadBomTemplate(options={}) { var format = options.format; diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 2f2d1f8ae4..fe912b3358 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -837,7 +837,15 @@ function getFormFieldElement(name, options) { var field_name = getFieldName(name, options); - var el = $(options.modal).find(`#id_${field_name}`); + var el = null; + + if (options && options.modal) { + // Field element is associated with a model? + el = $(options.modal).find(`#id_${field_name}`); + } else { + // Field element is top-level + el = $(`#id_${field_name}`); + } if (!el.exists) { console.log(`ERROR: Could not find form element for field '${name}'`); @@ -882,12 +890,13 @@ function validateFormField(name, options) { * - field: The field specification provided from the OPTIONS request * - options: The original options object provided by the client */ -function getFormFieldValue(name, field, options) { +function getFormFieldValue(name, field={}, options={}) { // Find the HTML element var el = getFormFieldElement(name, options); if (!el) { + console.log(`ERROR: getFormFieldValue could not locate field '{name}'`); return null; } @@ -973,16 +982,22 @@ function handleFormSuccess(response, options) { /* * Remove all error text items from the form */ -function clearFormErrors(options) { +function clearFormErrors(options={}) { - // Remove the individual error messages - $(options.modal).find('.form-error-message').remove(); + if (options && options.modal) { + // Remove the individual error messages + $(options.modal).find('.form-error-message').remove(); - // Remove the "has error" class - $(options.modal).find('.form-field-error').removeClass('form-field-error'); + // Remove the "has error" class + $(options.modal).find('.form-field-error').removeClass('form-field-error'); - // Hide the 'non field errors' - $(options.modal).find('#non-field-errors').html(''); + // Hide the 'non field errors' + $(options.modal).find('#non-field-errors').html(''); + } else { + $('.form-error-message').remove(); + $('.form-field-errors').removeClass('form-field-error'); + $('#non-field-errors').html(''); + } } /* @@ -1010,7 +1025,7 @@ function clearFormErrors(options) { * */ -function handleNestedErrors(errors, field_name, options) { +function handleNestedErrors(errors, field_name, options={}) { var error_list = errors[field_name]; @@ -1041,8 +1056,31 @@ function handleNestedErrors(errors, field_name, options) { // Here, error_item is a map of field names to error messages for (sub_field_name in error_item) { + var errors = error_item[sub_field_name]; + if (sub_field_name == 'non_field_errors') { + + var row = null; + + if (options.modal) { + row = $(options.modal).find(`#items_${nest_id}`); + } else { + row = $(`#items_${nest_id}`); + } + + for (var ii = errors.length - 1; ii >= 0; ii--) { + + var html = ` +
+ ${errors[ii]} +
`; + + row.after(html); + } + + } + // Find the target (nested) field var target = `${field_name}_${sub_field_name}_${nest_id}`; @@ -1066,15 +1104,23 @@ function handleNestedErrors(errors, field_name, options) { * - fields: The form data object * - options: Form options provided by the client */ -function handleFormErrors(errors, fields, options) { +function handleFormErrors(errors, fields={}, options={}) { // Reset the status of the "submit" button - $(options.modal).find('#modal-form-submit').prop('disabled', false); + if (options.modal) { + $(options.modal).find('#modal-form-submit').prop('disabled', false); + } // Remove any existing error messages from the form clearFormErrors(options); - var non_field_errors = $(options.modal).find('#non-field-errors'); + var non_field_errors = null; + + if (options.modal) { + non_field_errors = $(options.modal).find('#non-field-errors'); + } else { + non_field_errors = $('#non-field-errors'); + } // TODO: Display the JSON error text when hovering over the "info" icon non_field_errors.append( @@ -1150,14 +1196,19 @@ function handleFormErrors(errors, fields, options) { /* * Add a rendered error message to the provided field */ -function addFieldErrorMessage(name, error_text, error_idx, options) { +function addFieldErrorMessage(name, error_text, error_idx, options={}) { field_name = getFieldName(name, options); - // Add the 'form-field-error' class - $(options.modal).find(`#div_id_${field_name}`).addClass('form-field-error'); + var field_dom = null; - var field_dom = $(options.modal).find(`#errors-${field_name}`); + if (options.modal) { + $(options.modal).find(`#div_id_${field_name}`).addClass('form-field-error'); + field_dom = $(options.modal).find(`#errors-${field_name}`); + } else { + $(`#div_id_${field_name}`).addClass('form-field-error'); + field_dom = $(`#errors-${field_name}`); + } if (field_dom) { @@ -1228,12 +1279,18 @@ function addClearCallbacks(fields, options) { } -function addClearCallback(name, field, options) { +function addClearCallback(name, field, options={}) { var field_name = getFieldName(name, options); - var el = $(options.modal).find(`#clear_${field_name}`); - + var el = null; + + if (options && options.modal) { + el = $(options.modal).find(`#clear_${field_name}`); + } else { + el = $(`#clear_${field_name}`); + } + if (!el) { console.log(`WARNING: addClearCallback could not find field '${name}'`); return; @@ -1330,11 +1387,13 @@ function hideFormGroup(group, options) { $(options.modal).find(`#form-panel-${group}`).hide(); } + // Show a form group function showFormGroup(group, options) { $(options.modal).find(`#form-panel-${group}`).show(); } + function setFormGroupVisibility(group, vis, options) { if (vis) { showFormGroup(group, options); @@ -1344,7 +1403,7 @@ function setFormGroupVisibility(group, vis, options) { } -function initializeRelatedFields(fields, options) { +function initializeRelatedFields(fields, options={}) { var field_names = options.field_names; @@ -1452,12 +1511,11 @@ function addSecondaryModal(field, fields, options) { * - field: Field definition from the OPTIONS request * - options: Original options object provided by the client */ -function initializeRelatedField(field, fields, options) { +function initializeRelatedField(field, fields, options={}) { var name = field.name; if (!field.api_url) { - // TODO: Provide manual api_url option? console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`); return; } @@ -1475,10 +1533,22 @@ function initializeRelatedField(field, fields, options) { // limit size for AJAX requests var pageSize = options.pageSize || 25; + var parent = null; + var auto_width = false; + var width = '100%'; + + // Special considerations if the select2 input is a child of a modal + if (options && options.modal) { + parent = $(options.modal); + auto_width = true; + width = null; + } + select.select2({ placeholder: '', - dropdownParent: $(options.modal), - dropdownAutoWidth: false, + dropdownParent: parent, + dropdownAutoWidth: auto_width, + width: width, language: { noResults: function(query) { if (field.noResults) { @@ -1654,7 +1724,7 @@ function initializeRelatedField(field, fields, options) { * - data: JSON data representing the model instance * - options: The modal form specifications */ -function setRelatedFieldData(name, data, options) { +function setRelatedFieldData(name, data, options={}) { var select = getFormFieldElement(name, options); @@ -1779,10 +1849,10 @@ function renderModelData(name, model, data, parameters, options) { /* * Construct a field name for the given field */ -function getFieldName(name, options) { +function getFieldName(name, options={}) { var field_name = name; - if (options.depth) { + if (options && options.depth) { field_name += `_${options.depth}`; } @@ -1872,12 +1942,12 @@ function constructField(name, parameters, options) { options.current_group = group; } - var form_classes = 'form-group'; + var form_classes = options.form_classes || 'form-group'; if (parameters.errors) { form_classes += ' form-field-error'; } - + // Optional content to render before the field if (parameters.before) { html += parameters.before; @@ -1925,7 +1995,7 @@ function constructField(name, parameters, options) { if (extra) { - if (!parameters.required) { + if (!parameters.required && !options.hideClearButton) { html += ` @@ -2053,7 +2123,7 @@ function constructInput(name, parameters, options) { // Construct a set of default input options which apply to all input types -function constructInputOptions(name, classes, type, parameters) { +function constructInputOptions(name, classes, type, parameters, options={}) { var opts = []; @@ -2135,11 +2205,18 @@ function constructInputOptions(name, classes, type, parameters) { if (parameters.multiline) { return ``; } else if (parameters.type == 'boolean') { + + var help_text = ''; + + if (!options.hideLabels && parameters.help_text) { + help_text = `${parameters.help_text}`; + } + return `
`; @@ -2162,13 +2239,14 @@ function constructHiddenInput(name, parameters) { // Construct a "checkbox" input -function constructCheckboxInput(name, parameters) { +function constructCheckboxInput(name, parameters, options={}) { return constructInputOptions( name, 'form-check-input', 'checkbox', - parameters + parameters, + options ); } diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index ee4c4cb5ef..68ba496309 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -161,7 +161,7 @@ function renderPart(name, data, parameters, options) { html += ` ${data.full_name || data.name}`; if (data.description) { - html += ` - ${data.description}`; + html += ` - ${data.description}`; } var extra = '';