mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +00:00 
			
		
		
		
	Merge pull request #2603 from SchrodingersGat/bom-upload-improvements
Bom upload improvements
This commit is contained in:
		| @@ -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") | ||||
|     ) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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'), | ||||
| ] | ||||
|   | ||||
| @@ -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)) | ||||
|   | ||||
| @@ -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 %} | ||||
| <div class='alert alert-danger alert-block' style='margin-top:12px;' role='alert'> | ||||
|     {% trans "Missing selections for the following required columns" %}: | ||||
|     <br> | ||||
|     <ul> | ||||
|         {% for col in missing_columns %} | ||||
|         <li>{{ col }}</li> | ||||
|         {% endfor %} | ||||
|     </ul> | ||||
| </div> | ||||
| {% endif %} | ||||
| {% if duplicates and duplicates|length > 0 %} | ||||
| <div class='alert alert-danger alert-block' role='alert'> | ||||
|     {% trans "Duplicate selections found, see below. Fix them then retry submitting." %} | ||||
| </div> | ||||
| {% endif %} | ||||
| {% endblock form_alert %} | ||||
|  | ||||
| {% block form_buttons_top %} | ||||
|     {% if wizard.steps.prev %} | ||||
|     <button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-outline-secondary">{% trans "Previous Step" %}</button> | ||||
|     {% endif %} | ||||
|     <button type="submit" class="save btn btn-outline-secondary">{% trans "Submit Selections" %}</button> | ||||
| {% endblock form_buttons_top %} | ||||
|  | ||||
| {% block form_content %} | ||||
|     <thead> | ||||
|         <tr> | ||||
|             <th>{% trans "File Fields" %}</th> | ||||
|             <th></th> | ||||
|             {% for col in form %} | ||||
|             <th> | ||||
|                 <div> | ||||
|                     <input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/> | ||||
|                     {{ col.name }} | ||||
|                     <button class='btn btn-outline-secondary btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'> | ||||
|                         <span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span> | ||||
|                     </button> | ||||
|                 </div> | ||||
|             </th> | ||||
|             {% endfor %} | ||||
|         </tr> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|         <tr> | ||||
|             <td>{% trans "Match Fields" %}</td> | ||||
|             <td></td> | ||||
|             {% for col in form %} | ||||
|             <td> | ||||
|                 {{ col }} | ||||
|                 {% for duplicate in duplicates %} | ||||
|                     {% if duplicate == col.value %} | ||||
|                     <div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'> | ||||
|                         <strong>{% trans "Duplicate selection" %}</strong> | ||||
|                     </div> | ||||
|                     {% endif %} | ||||
|                 {% endfor %} | ||||
|             </td> | ||||
|             {% endfor %} | ||||
|         </tr> | ||||
|         {% for row in rows %} | ||||
|         {% with forloop.counter as row_index %} | ||||
|         <tr> | ||||
|             <td style='width: 32px;'> | ||||
|                 <button class='btn btn-outline-secondary btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row_index }}' style='display: inline; float: left;' title='{% trans "Remove row" %}'> | ||||
|                     <span row_id='{{ row_index }}' class='fas fa-trash-alt icon-red'></span> | ||||
|                 </button> | ||||
|             </td> | ||||
|             <td style='text-align: left;'>{{ row_index }}</td> | ||||
|             {% for item in row.data %} | ||||
|             <td> | ||||
|                 <input type='hidden' name='row_{{ row_index }}_col_{{ forloop.counter0 }}' value='{{ item }}'/> | ||||
|                 {{ item }} | ||||
|             </td> | ||||
|             {% endfor %} | ||||
|         </tr> | ||||
|         {% endwith %} | ||||
|         {% endfor %} | ||||
|     </tbody> | ||||
| {% endblock form_content %} | ||||
|  | ||||
| {% block form_buttons_bottom %} | ||||
| {% endblock form_buttons_bottom %} | ||||
|  | ||||
| {% block js_ready %} | ||||
| {{ block.super }} | ||||
|  | ||||
| $('.fieldselect').select2({ | ||||
|     width: '100%', | ||||
|     matcher: partialMatcher, | ||||
| }); | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -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 %} | ||||
| <div class='alert alert-danger alert-block' role='alert'> | ||||
|     {% trans "Errors exist in the submitted data" %} | ||||
| </div> | ||||
| {% endif %} | ||||
| {% endblock form_alert %} | ||||
|  | ||||
| {% block form_buttons_top %} | ||||
|     {% if wizard.steps.prev %} | ||||
|     <button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-outline-secondary">{% trans "Previous Step" %}</button> | ||||
|     {% endif %} | ||||
|     <button type="submit" class="save btn btn-outline-secondary">{% trans "Submit Selections" %}</button> | ||||
| {% endblock form_buttons_top %} | ||||
|  | ||||
| {% block form_content %} | ||||
|         <thead> | ||||
|             <tr> | ||||
|                 <th></th> | ||||
|                 <th>{% trans "Row" %}</th> | ||||
|                 <th>{% trans "Select Part" %}</th> | ||||
|                 <th>{% trans "Reference" %}</th> | ||||
|                 <th>{% trans "Quantity" %}</th> | ||||
|                 {% for col in columns %} | ||||
|                 {% if col.guess != 'Quantity' %} | ||||
|                 <th> | ||||
|                     <input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/> | ||||
|                     <input type='hidden' name='col_guess_{{ forloop.counter0 }}' value='{{ col.guess }}'/> | ||||
|                     {% if col.guess %} | ||||
|                     {{ col.guess }} | ||||
|                     {% else %} | ||||
|                     {{ col.name }} | ||||
|                     {% endif %} | ||||
|                 </th> | ||||
|                 {% endif %} | ||||
|                 {% endfor %} | ||||
|             </tr> | ||||
|         </thead> | ||||
|         <tbody> | ||||
|              <tr></tr> {% comment %} Dummy row for javascript del_row method {% endcomment %} | ||||
|             {% for row in rows %} | ||||
|             <tr {% if row.errors %} style='background: #ffeaea;'{% endif %} part-select='#select_part_{{ row.index }}'> | ||||
|                 <td> | ||||
|                     <button class='btn btn-outline-secondary btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row.index }}' style='display: inline; float: right;' title='{% trans "Remove row" %}'> | ||||
|                             <span row_id='{{ row.index }}' class='fas fa-trash-alt icon-red'></span> | ||||
|                     </button> | ||||
|                 </td> | ||||
|                 <td> | ||||
|                 {% add row.index 1 %} | ||||
|                 </td> | ||||
|                 <td> | ||||
|                     {% for field in form.visible_fields %} | ||||
|                         {% if field.name == row.item_select %} | ||||
|                             {{ field }} | ||||
|                         {% endif %} | ||||
|                     {% endfor %} | ||||
|                     {% if row.errors.part %} | ||||
|                     <p class='help-inline'>{{ row.errors.part }}</p> | ||||
|                     {% endif %} | ||||
|                 </td> | ||||
|                 <td> | ||||
|                     {% for field in form.visible_fields %} | ||||
|                         {% if field.name == row.reference %} | ||||
|                             {{ field|as_crispy_field }} | ||||
|                         {% endif %} | ||||
|                     {% endfor %} | ||||
|                     {% if row.errors.reference %} | ||||
|                         <p class='help-inline'>{{ row.errors.reference }}</p> | ||||
|                     {% endif %} | ||||
|                 </td> | ||||
|                 <td> | ||||
|                     {% for field in form.visible_fields %} | ||||
|                         {% if field.name == row.quantity %} | ||||
|                             {{ field|as_crispy_field }} | ||||
|                         {% endif %} | ||||
|                     {% endfor %} | ||||
|                     {% if row.errors.quantity %} | ||||
|                         <p class='help-inline'>{{ row.errors.quantity }}</p> | ||||
|                     {% endif %} | ||||
|                 </td> | ||||
|                 {% for item in row.data %} | ||||
|                 {% if item.column.guess != 'Quantity' %} | ||||
|                 <td> | ||||
|                     {% 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 %} | ||||
|                     <input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/> | ||||
|                 </td> | ||||
|                 {% endif %} | ||||
|                 {% endfor %} | ||||
|             </tr> | ||||
|             {% endfor %} | ||||
|         </tbody> | ||||
| {% endblock form_content %} | ||||
|  | ||||
| {% block form_buttons_bottom %} | ||||
| {% endblock form_buttons_bottom %} | ||||
|  | ||||
| {% block js_ready %} | ||||
| {{ block.super }} | ||||
|  | ||||
| $('.bomselect').select2({ | ||||
|     dropdownAutoWidth: true, | ||||
|     matcher: partialMatcher, | ||||
| }); | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -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 %} | ||||
| <div class='panel-content'> | ||||
|     <p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %} | ||||
|     {% if description %}- {{ description }}{% endif %}</p> | ||||
|  | ||||
|     <form action="" method="post" class='js-modal-form' enctype="multipart/form-data"> | ||||
|     {% csrf_token %} | ||||
|     {% load crispy_forms_tags %} | ||||
|  | ||||
|     {% block form_buttons_top %} | ||||
|     {% endblock form_buttons_top %} | ||||
|  | ||||
|     {% block form_alert %} | ||||
|     <div class='alert alert-info alert-block'> | ||||
|         <strong>{% trans "Requirements for BOM upload" %}:</strong> | ||||
|         <ul> | ||||
|             <li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href='#' id='bom-template-download'>{% trans "BOM Upload Template" %}</a></strong></li> | ||||
|             <li>{% trans "Each part must already exist in the database" %}</li> | ||||
|         </ul> | ||||
|     </div> | ||||
|     {% endblock %}  | ||||
|  | ||||
|     <table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'> | ||||
|     {{ wizard.management_form }} | ||||
|     {% block form_content %} | ||||
|     {% crispy wizard.form %} | ||||
|     {% endblock form_content %} | ||||
|     </table> | ||||
|  | ||||
|     {% block form_buttons_bottom %} | ||||
|     {% if wizard.steps.prev %} | ||||
|     <button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-outline-secondary">{% trans "Previous Step" %}</button> | ||||
|     {% endif %} | ||||
|     <button type="submit" class="save btn btn-outline-secondary">{% trans "Upload File" %}</button> | ||||
|     </form> | ||||
|     {% endblock form_buttons_bottom %} | ||||
| </div> | ||||
| {% endblock page_info %} | ||||
|  | ||||
| {% block js_ready %} | ||||
| {{ block.super }} | ||||
|  | ||||
| enableSidebar('bom-upload'); | ||||
|  | ||||
| $('#bom-template-download').click(function() { | ||||
|     downloadBomTemplate(); | ||||
| }); | ||||
|  | ||||
| {% endblock js_ready %} | ||||
							
								
								
									
										105
									
								
								InvenTree/part/templates/part/upload_bom.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								InvenTree/part/templates/part/upload_bom.html
									
									
									
									
									
										Normal file
									
								
							| @@ -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 %} | ||||
| <!-- | ||||
| <button type='button' class='btn btn-outline-secondary' id='bom-info'> | ||||
|     <span class='fas fa-info-circle' title='{% trans "BOM upload requirements" %}'></span> | ||||
| </button> | ||||
| --> | ||||
| <button type='button' class='btn btn-primary' id='bom-upload'> | ||||
|     <span class='fas fa-file-upload'></span> {% trans "Upload BOM File" %} | ||||
| </button> | ||||
| <button type='button' class='btn btn-success' id='bom-submit' style='display: none;'> | ||||
|     <span class='fas fa-sign-in-alt'></span> {% trans "Submit BOM Data" %} | ||||
| </button> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block page_info %} | ||||
| <div class='panel-content'> | ||||
|  | ||||
|     <div class='alert alert-info alert-block'> | ||||
|         <strong>{% trans "Requirements for BOM upload" %}:</strong> | ||||
|         <ul> | ||||
|             <li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href='#' id='bom-template-download'>{% trans "BOM Upload Template" %}</a></strong></li> | ||||
|             <li>{% trans "Each part must already exist in the database" %}</li> | ||||
|         </ul> | ||||
|     </div> | ||||
|  | ||||
|     <div id='non-field-errors'> | ||||
|         <!-- Upload error messages go here --> | ||||
|     </div> | ||||
|  | ||||
|     <!-- This table is filled out after BOM file is uploaded and processed --> | ||||
|     <table class='table table-striped table-condensed' id='bom-import-table'> | ||||
|         <thead> | ||||
|             <tr> | ||||
|                 <th style='max-width: 500px;'>{% trans "Part" %}</th> | ||||
|                 <th>{% trans "Quantity" %}</th> | ||||
|                 <th>{% trans "Reference" %}</th> | ||||
|                 <th>{% trans "Overage" %}</th> | ||||
|                 <th>{% trans "Allow Variants" %}</th> | ||||
|                 <th>{% trans "Inherited" %}</th> | ||||
|                 <th>{% trans "Optional" %}</th> | ||||
|                 <th>{% trans "Note" %}</th> | ||||
|                 <th><!-- Buttons Column --></th> | ||||
|             </tr> | ||||
|         </thead> | ||||
|         <tbody></tbody> | ||||
|     </table> | ||||
|  | ||||
| </div> | ||||
| {% 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 %} | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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'), | ||||
|   | ||||
| @@ -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 """ | ||||
|  | ||||
|   | ||||
| @@ -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 = `<div class='btn-group float-right' role='group'>`; | ||||
|  | ||||
|         // 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 += `</div>`; | ||||
|  | ||||
|         var html = ` | ||||
|         <tr id='items_${idx}' class='bom-import-row' idx='${idx}'> | ||||
|             <td id='col_sub_part_${idx}'>${sub_part}</td> | ||||
|             <td id='col_quantity_${idx}'>${quantity}</td> | ||||
|             <td id='col_reference_${idx}'>${reference}</td> | ||||
|             <td id='col_overage_${idx}'>${overage}</td> | ||||
|             <td id='col_variants_${idx}'>${variants}</td> | ||||
|             <td id='col_inherited_${idx}'>${inherited}</td> | ||||
|             <td id='col_optional_${idx}'>${optional}</td> | ||||
|             <td id='col_note_${idx}'>${note}</td> | ||||
|             <td id='col_buttons_${idx}'>${buttons}</td> | ||||
|         </tr>`; | ||||
|  | ||||
|         $('#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; | ||||
|   | ||||
| @@ -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 = ` | ||||
|                     <div id='error_${ii}_non_field_error' class='help-block form-field-error form-error-message'> | ||||
|                         <strong>${errors[ii]}</strong> | ||||
|                     </div>`; | ||||
|  | ||||
|                     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 += ` | ||||
|             <span class='input-group-text form-clear' id='clear_${field_name}' title='{% trans "Clear input" %}'> | ||||
|                 <span class='icon-red fas fa-backspace'></span> | ||||
| @@ -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 `<textarea ${opts.join(' ')}></textarea>`; | ||||
|     } else if (parameters.type == 'boolean') { | ||||
|  | ||||
|         var help_text = ''; | ||||
|  | ||||
|         if (!options.hideLabels && parameters.help_text) { | ||||
|             help_text = `<em><small>${parameters.help_text}</small></em>`; | ||||
|         } | ||||
|  | ||||
|         return ` | ||||
|         <div class='form-check form-switch'> | ||||
|             <input ${opts.join(' ')}> | ||||
|             <label class='form-check-label' for=''> | ||||
|                 <em><small>${parameters.help_text}</small></em> | ||||
|                 ${help_text} | ||||
|             </label> | ||||
|         </div> | ||||
|         `; | ||||
| @@ -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 | ||||
|     ); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -161,7 +161,7 @@ function renderPart(name, data, parameters, options) { | ||||
|     html += ` <span>${data.full_name || data.name}</span>`; | ||||
|  | ||||
|     if (data.description) { | ||||
|         html += ` - <i>${data.description}</i>`; | ||||
|         html += ` - <i><small>${data.description}</small></i>`; | ||||
|     } | ||||
|  | ||||
|     var extra = ''; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user