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 %}
-
- {% trans "Missing selections for the following required columns" %}:
-
-
- {% for col in missing_columns %}
-
{{ col }}
- {% endfor %}
-
-
-{% endif %}
-{% if duplicates and duplicates|length > 0 %}
-
- {% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
-
{% trans "Each part must already exist in the database" %}
+
+
+
+
+
+
+
+
+
+
+
+
{% 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 = `