diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index fceaf9a58f..e0bc1ccb95 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -23,6 +23,7 @@ import moneyed import yaml from django.utils.translation import gettext_lazy as _ +from django.contrib.messages import constants as messages def _is_true(x): @@ -611,3 +612,9 @@ IMPORT_EXPORT_USE_TRANSACTIONS = True INTERNAL_IPS = [ '127.0.0.1', ] + +MESSAGE_TAGS = { + messages.SUCCESS: 'alert alert-block alert-success', + messages.ERROR: 'alert alert-block alert-danger', + messages.INFO: 'alert alert-block alert-info', +} diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index eed6c6ad21..e4d12576fa 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -718,7 +718,7 @@ position:relative; height: auto !important; max-height: calc(100vh - 200px) !important; - overflow-y: scroll; + overflow-y: auto; padding: 10px; } diff --git a/InvenTree/common/files.py b/InvenTree/common/files.py index 377120f44d..f805ceaace 100644 --- a/InvenTree/common/files.py +++ b/InvenTree/common/files.py @@ -22,17 +22,19 @@ class FileManager: # Fields which are used for item matching (only one of them is needed) ITEM_MATCH_HEADERS = [] - + # Fields which would be helpful but are not required OPTIONAL_HEADERS = [] + OPTIONAL_MATCH_HEADERS = [] + EDITABLE_HEADERS = [] HEADERS = [] def __init__(self, file, name=None): """ Initialize the FileManager class with a user-uploaded file object """ - + # Set name if name: self.name = name @@ -71,47 +73,34 @@ class FileManager: raise ValidationError(_('Error reading file (incorrect dimension)')) except KeyError: raise ValidationError(_('Error reading file (data could be corrupted)')) - + return cleaned_data def process(self, file): """ Process file """ self.data = self.__class__.validate(file) - + def update_headers(self): """ Update headers """ - self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_HEADERS - + self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_MATCH_HEADERS + self.OPTIONAL_HEADERS + def setup(self): - """ Setup headers depending on the file name """ + """ + Setup headers + should be overriden in usage to set the Different Headers + """ if not self.name: return - if self.name == 'order': - self.REQUIRED_HEADERS = [ - 'Quantity', - ] - - self.ITEM_MATCH_HEADERS = [ - 'Manufacturer_MPN', - 'Supplier_SKU', - ] - - self.OPTIONAL_HEADERS = [ - 'Purchase_Price', - 'Reference', - 'Notes', - ] - - # Update headers - self.update_headers() + # Update headers + self.update_headers() def guess_header(self, header, threshold=80): """ Try to match a header (from the file) to a list of known headers - + Args: header - Header name to look for threshold - Match threshold for fuzzy search @@ -145,7 +134,7 @@ class FileManager: return matches[0]['header'] return None - + def columns(self): """ Return a list of headers for the thingy """ headers = [] diff --git a/InvenTree/common/forms.py b/InvenTree/common/forms.py index f161c8cc01..4a2a1601aa 100644 --- a/InvenTree/common/forms.py +++ b/InvenTree/common/forms.py @@ -8,12 +8,7 @@ from __future__ import unicode_literals from django import forms from django.utils.translation import gettext as _ -from djmoney.forms.fields import MoneyField - from InvenTree.forms import HelperForm -from InvenTree.helpers import clean_decimal - -from common.settings import currency_code_default from .files import FileManager from .models import InvenTreeSetting @@ -32,7 +27,7 @@ class SettingEditForm(HelperForm): ] -class UploadFile(forms.Form): +class UploadFileForm(forms.Form): """ Step 1 of FileManagementFormView """ file = forms.FileField( @@ -70,9 +65,9 @@ class UploadFile(forms.Form): return file -class MatchField(forms.Form): +class MatchFieldForm(forms.Form): """ Step 2 of FileManagementFormView """ - + def __init__(self, *args, **kwargs): # Get FileManager @@ -88,7 +83,7 @@ class MatchField(forms.Form): columns = file_manager.columns() # Get headers choices headers_choices = [(header, header) for header in file_manager.HEADERS] - + # Create column fields for col in columns: field_name = col['name'] @@ -103,9 +98,9 @@ class MatchField(forms.Form): self.fields[field_name].initial = col['guess'] -class MatchItem(forms.Form): +class MatchItemForm(forms.Form): """ Step 3 of FileManagementFormView """ - + def __init__(self, *args, **kwargs): # Get FileManager @@ -131,24 +126,41 @@ class MatchItem(forms.Form): for col in row['data']: # Get column matching col_guess = col['column'].get('guess', None) + # Set field name + field_name = col_guess.lower() + '-' + str(row['index']) + + # check if field def was overriden + overriden_field = self.get_special_field(col_guess, row, file_manager) + if overriden_field: + self.fields[field_name] = overriden_field # Create input for required headers - if col_guess in file_manager.REQUIRED_HEADERS: - # Set field name - field_name = col_guess.lower() + '-' + str(row['index']) + elif col_guess in file_manager.REQUIRED_HEADERS: + # Get value + value = row.get(col_guess.lower(), '') # Set field input box - if 'quantity' in col_guess.lower(): - self.fields[field_name] = forms.CharField( - required=False, - widget=forms.NumberInput(attrs={ - 'name': 'quantity' + str(row['index']), - 'class': 'numberinput', # form-control', - 'type': 'number', - 'min': '0', - 'step': 'any', - 'value': clean_decimal(row.get('quantity', '')), - }) - ) + self.fields[field_name] = forms.CharField( + required=True, + initial=value, + ) + + # Create item selection box + elif col_guess in file_manager.OPTIONAL_MATCH_HEADERS: + # Get item options + item_options = [(option.id, option) for option in row['match_options_' + col_guess]] + # Get item match + item_match = row['match_' + col_guess] + # Set field select box + self.fields[field_name] = forms.ChoiceField( + choices=[('', '-' * 10)] + item_options, + required=False, + widget=forms.Select(attrs={ + 'class': 'select bomselect', + }) + ) + # Update select box when match was found + if item_match: + self.fields[field_name].initial = item_match.id # Create item selection box elif col_guess in file_manager.ITEM_MATCH_HEADERS: @@ -176,22 +188,15 @@ class MatchItem(forms.Form): # Optional entries elif col_guess in file_manager.OPTIONAL_HEADERS: - # Set field name - field_name = col_guess.lower() + '-' + str(row['index']) # Get value value = row.get(col_guess.lower(), '') # Set field input box - if 'price' in col_guess.lower(): - self.fields[field_name] = MoneyField( - label=_(col_guess), - default_currency=currency_code_default(), - decimal_places=5, - max_digits=19, - required=False, - default_amount=clean_decimal(value), - ) - else: - self.fields[field_name] = forms.CharField( - required=False, - initial=value, - ) + self.fields[field_name] = forms.CharField( + required=False, + initial=value, + ) + + def get_special_field(self, col_guess, row, file_manager): + """ Function to be overriden in inherited forms to add specific form settings """ + + return None diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index b78fe1c2bf..ddd27b734e 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -205,6 +205,13 @@ class InvenTreeSetting(models.Model): 'validator': bool, }, + 'PART_SHOW_IMPORT': { + 'name': _('Show Import in Views'), + 'description': _('Display the import wizard in some part views'), + 'default': False, + 'validator': bool, + }, + 'PART_SHOW_PRICE_IN_FORMS': { 'name': _('Show Price in Forms'), 'description': _('Display part price in some forms'), diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index 857b6b2c51..f953dffa81 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -13,8 +13,9 @@ from django.conf import settings from django.core.files.storage import FileSystemStorage from formtools.wizard.views import SessionWizardView +from crispy_forms.helper import FormHelper -from InvenTree.views import AjaxUpdateView +from InvenTree.views import AjaxUpdateView, AjaxView from InvenTree.helpers import str2bool from . import models @@ -117,7 +118,6 @@ class MultiStepFormView(SessionWizardView): form_steps_description: description for each form """ - form_list = [] form_steps_template = [] form_steps_description = [] file_manager = None @@ -126,10 +126,10 @@ class MultiStepFormView(SessionWizardView): def __init__(self, *args, **kwargs): """ Override init method to set media folder """ - super().__init__(*args, **kwargs) + super().__init__(**kwargs) self.process_media_folder() - + def process_media_folder(self): """ Process media folder """ @@ -141,7 +141,7 @@ class MultiStepFormView(SessionWizardView): def get_template_names(self): """ Select template """ - + try: # Get template template = self.form_steps_template[self.steps.index] @@ -152,7 +152,7 @@ class MultiStepFormView(SessionWizardView): def get_context_data(self, **kwargs): """ Update context data """ - + # Retrieve current context context = super().get_context_data(**kwargs) @@ -176,9 +176,9 @@ class FileManagementFormView(MultiStepFormView): name = None form_list = [ - ('upload', forms.UploadFile), - ('fields', forms.MatchField), - ('items', forms.MatchItem), + ('upload', forms.UploadFileForm), + ('fields', forms.MatchFieldForm), + ('items', forms.MatchItemForm), ] form_steps_description = [ _("Upload File"), @@ -188,11 +188,26 @@ class FileManagementFormView(MultiStepFormView): media_folder = 'file_upload/' extra_context_data = {} - def get_context_data(self, form, **kwargs): + def __init__(self, *args, **kwargs): + """ Initialize the FormView """ + + # Perform all checks and inits for MultiStepFormView + super().__init__(self, *args, **kwargs) + + # Check for file manager class + if not hasattr(self, 'file_manager_class') and not issubclass(self.file_manager_class, FileManager): + raise NotImplementedError('A subclass of a file manager class needs to be set!') + + def get_context_data(self, form=None, **kwargs): + """ Handle context data """ + + if form is None: + form = self.get_form() + context = super().get_context_data(form=form, **kwargs) if self.steps.current in ('fields', 'items'): - + # Get columns and row data self.columns = self.file_manager.columns() self.rows = self.file_manager.rows() @@ -203,7 +218,7 @@ class FileManagementFormView(MultiStepFormView): elif self.steps.current == 'items': # Set form table data self.set_form_table_data(form=form) - + # Update context context.update({'rows': self.rows}) context.update({'columns': self.columns}) @@ -227,7 +242,7 @@ class FileManagementFormView(MultiStepFormView): # Get file file = upload_files.get('upload-file', None) if file: - self.file_manager = FileManager(file=file, name=self.name) + self.file_manager = self.file_manager_class(file=file, name=self.name) def get_form_kwargs(self, step=None): """ Update kwargs to dynamically build forms """ @@ -262,13 +277,22 @@ class FileManagementFormView(MultiStepFormView): self.get_form_table_data(data) self.set_form_table_data() self.get_field_selection() - + kwargs['row_data'] = self.rows return kwargs - + return super().get_form_kwargs() + def get_form(self, step=None, data=None, files=None): + """ add crispy-form helper to form """ + form = super().get_form(step=step, data=data, files=files) + + form.helper = FormHelper() + form.helper.form_show_labels = False + + return form + def get_form_table_data(self, form_data): """ Extract table cell data from form data and fields. These data are used to maintain state between sessions. @@ -327,7 +351,7 @@ class FileManagementFormView(MultiStepFormView): col_id = int(s[3]) except ValueError: continue - + if row_id not in self.row_data: self.row_data[row_id] = {} @@ -362,19 +386,20 @@ class FileManagementFormView(MultiStepFormView): 'name': self.column_names[idx], 'guess': self.column_selections[idx], } - + cell_data = { 'cell': item, 'idx': idx, 'column': column_data, } data.append(cell_data) - + row = { 'index': row_idx, 'data': data, 'errors': {}, } + self.rows.append(row) # In the item selection step: update row data with mapping to form fields @@ -414,6 +439,33 @@ class FileManagementFormView(MultiStepFormView): """ pass + def get_clean_items(self): + """ returns dict with all cleaned values """ + items = {} + + for form_key, form_value in self.get_all_cleaned_data().items(): + # Split key from row value + try: + (field, idx) = form_key.split('-') + except ValueError: + continue + + try: + if idx not in items: + # Insert into items + items.update({ + idx: { + self.form_field_map[field]: form_value, + } + }) + else: + # Update items + items[idx][self.form_field_map[field]] = form_value + except KeyError: + pass + + return items + def check_field_selection(self, form): """ Check field matching """ @@ -431,7 +483,7 @@ class FileManagementFormView(MultiStepFormView): if col in self.column_selections.values(): part_match_found = True break - + # If not, notify user if not part_match_found: for col in self.file_manager.ITEM_MATCH_HEADERS: @@ -451,7 +503,7 @@ class FileManagementFormView(MultiStepFormView): n = list(self.column_selections.values()).count(self.column_selections[col]) if n > 1 and self.column_selections[col] not in duplicates: duplicates.append(self.column_selections[col]) - + # Store extra context data self.extra_context_data = { 'missing_columns': missing_columns, @@ -497,3 +549,70 @@ class FileManagementFormView(MultiStepFormView): return self.render(form) return super().post(*args, **kwargs) + + +class FileManagementAjaxView(AjaxView): + """ Use a FileManagementFormView as base for a AjaxView + Inherit this class before inheriting the base FileManagementFormView + + ajax_form_steps_template: templates for rendering ajax + validate: function to validate the current form -> normally point to the same function in the base FileManagementFormView + """ + + def post(self, request): + # check if back-step button was selected + wizard_back = self.request.POST.get('act-btn_back', None) + if wizard_back: + back_step_index = self.get_step_index() - 1 + self.storage.current_step = list(self.get_form_list().keys())[back_step_index] + return self.renderJsonResponse(request, data={'form_valid': None}) + + # validate form + form = self.get_form(data=self.request.POST, files=self.request.FILES) + form_valid = self.validate(self.steps.current, form) + + # check if valid + if not form_valid: + return self.renderJsonResponse(request, data={'form_valid': None}) + + # store the cleaned data and files. + self.storage.set_step_data(self.steps.current, self.process_step(form)) + self.storage.set_step_files(self.steps.current, self.process_step_files(form)) + + # check if the current step is the last step + if self.steps.current == self.steps.last: + # call done - to process data, returned response is not used + self.render_done(form) + data = {'form_valid': True, 'success': _('Parts imported')} + return self.renderJsonResponse(request, data=data) + else: + self.storage.current_step = self.steps.next + + return self.renderJsonResponse(request, data={'form_valid': None}) + + def get(self, request): + if 'reset' in request.GET: + # reset form + self.storage.reset() + self.storage.current_step = self.steps.first + return self.renderJsonResponse(request) + + def renderJsonResponse(self, request, form=None, data={}, context=None): + """ always set the right templates before rendering """ + self.setTemplate() + return super().renderJsonResponse(request, form=form, data=data, context=context) + + def get_data(self): + data = super().get_data() + data['hideErrorMessage'] = '1' # hide the error + buttons = [{'name': 'back', 'title': _('Previous Step')}] if self.get_step_index() > 0 else [] + data['buttons'] = buttons # set buttons + return data + + def setTemplate(self): + """ set template name and title """ + self.ajax_template_name = self.ajax_form_steps_template[self.get_step_index()] + self.ajax_form_title = self.form_steps_description[self.get_step_index()] + + def validate(self, obj, form, **kwargs): + raise NotImplementedError('This function needs to be overridden!') diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index effa696d43..48b5245a5f 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -10,10 +10,17 @@ from django.utils.translation import ugettext_lazy as _ from mptt.fields import TreeNodeChoiceField +from djmoney.forms.fields import MoneyField + from InvenTree.forms import HelperForm from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import DatePickerFormField +from InvenTree.helpers import clean_decimal + +from common.models import InvenTreeSetting +from common.forms import MatchItemForm + import part.models from stock.models import StockLocation @@ -291,3 +298,37 @@ class EditSalesOrderAllocationForm(HelperForm): 'line', 'item', 'quantity'] + + +class OrderMatchItemForm(MatchItemForm): + """ Override MatchItemForm fields """ + + def get_special_field(self, col_guess, row, file_manager): + """ Set special fields """ + + # set quantity field + if 'quantity' in col_guess.lower(): + return forms.CharField( + required=False, + widget=forms.NumberInput(attrs={ + 'name': 'quantity' + str(row['index']), + 'class': 'numberinput', + 'type': 'number', + 'min': '0', + 'step': 'any', + 'value': clean_decimal(row.get('quantity', '')), + }) + ) + # set price field + elif 'price' in col_guess.lower(): + return MoneyField( + label=_(col_guess), + default_currency=InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY'), + decimal_places=5, + max_digits=19, + required=False, + default_amount=clean_decimal(row.get('purchase_price', '')), + ) + + # return default + return super().get_special_field(col_guess, row, file_manager) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 821e8bf343..e527b3cec9 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -17,8 +17,7 @@ from InvenTree.serializers import InvenTreeAttachmentSerializerField from company.serializers import CompanyBriefSerializer, SupplierPartSerializer from part.serializers import PartBriefSerializer -from stock.serializers import LocationBriefSerializer -from stock.serializers import StockItemSerializer, LocationSerializer +from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer from .models import PurchaseOrder, PurchaseOrderLineItem from .models import PurchaseOrderAttachment, SalesOrderAttachment diff --git a/InvenTree/order/templates/order/order_wizard/match_parts.html b/InvenTree/order/templates/order/order_wizard/match_parts.html index f97edff913..e0f030bad5 100644 --- a/InvenTree/order/templates/order/order_wizard/match_parts.html +++ b/InvenTree/order/templates/order/order_wizard/match_parts.html @@ -2,6 +2,7 @@ {% load inventree_extras %} {% load i18n %} {% load static %} +{% load crispy_forms_tags %} {% block form_alert %} {% if form.errors %} @@ -67,7 +68,7 @@ {% for field in form.visible_fields %} {% if field.name == row.quantity %} - {{ field }} + {{ field|as_crispy_field }} {% endif %} {% endfor %} {% if row.errors.quantity %} @@ -80,19 +81,19 @@ {% if item.column.guess == 'Purchase_Price' %} {% for field in form.visible_fields %} {% if field.name == row.purchase_price %} - {{ field }} + {{ field|as_crispy_field }} {% endif %} {% endfor %} {% elif item.column.guess == 'Reference' %} {% for field in form.visible_fields %} {% if field.name == row.reference %} - {{ field }} + {{ field|as_crispy_field }} {% endif %} {% endfor %} {% elif item.column.guess == 'Notes' %} {% for field in form.visible_fields %} {% if field.name == row.notes %} - {{ field }} + {{ field|as_crispy_field }} {% endif %} {% endfor %} {% else %} diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 4a8e576a6d..98f3384ca9 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -30,7 +30,9 @@ from stock.models import StockItem, StockLocation from part.models import Part from common.models import InvenTreeSetting +from common.forms import UploadFileForm, MatchFieldForm from common.views import FileManagementFormView +from common.files import FileManager from . import forms as order_forms from part.views import PartPricing @@ -572,7 +574,28 @@ class SalesOrderShip(AjaxUpdateView): class PurchaseOrderUpload(FileManagementFormView): ''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) ''' + class OrderFileManager(FileManager): + REQUIRED_HEADERS = [ + 'Quantity', + ] + + ITEM_MATCH_HEADERS = [ + 'Manufacturer_MPN', + 'Supplier_SKU', + ] + + OPTIONAL_HEADERS = [ + 'Purchase_Price', + 'Reference', + 'Notes', + ] + name = 'order' + form_list = [ + ('upload', UploadFileForm), + ('fields', MatchFieldForm), + ('items', order_forms.OrderMatchItemForm), + ] form_steps_template = [ 'order/order_wizard/po_upload.html', 'order/order_wizard/match_fields.html', @@ -583,7 +606,6 @@ class PurchaseOrderUpload(FileManagementFormView): _("Match Fields"), _("Match Supplier Parts"), ] - # Form field name: PurchaseOrderLineItem field form_field_map = { 'item_select': 'part', 'quantity': 'quantity', @@ -591,6 +613,7 @@ class PurchaseOrderUpload(FileManagementFormView): 'reference': 'reference', 'notes': 'notes', } + file_manager_class = OrderFileManager def get_order(self): """ Get order or return 404 """ @@ -598,6 +621,8 @@ class PurchaseOrderUpload(FileManagementFormView): return get_object_or_404(PurchaseOrder, pk=self.kwargs['pk']) def get_context_data(self, form, **kwargs): + """ Handle context data for order """ + context = super().get_context_data(form=form, **kwargs) order = self.get_order() @@ -708,26 +733,7 @@ class PurchaseOrderUpload(FileManagementFormView): """ Once all the data is in, process it to add PurchaseOrderLineItem instances to the order """ order = self.get_order() - - items = {} - - for form_key, form_value in self.get_all_cleaned_data().items(): - # Split key from row value - try: - (field, idx) = form_key.split('-') - except ValueError: - continue - - if idx not in items: - # Insert into items - items.update({ - idx: { - self.form_field_map[field]: form_value, - } - }) - else: - # Update items - items[idx][self.form_field_map[field]] = form_value + items = self.get_clean_items() # Create PurchaseOrderLineItem instances for purchase_order_item in items.values(): diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 44cbeac99f..ef250d4c89 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -8,6 +8,15 @@ {% block content %} +{% if messages %} + {% for message in messages %} +
+ {{ message|safe }} +
+ {% endfor %} +{% endif %} + +
diff --git a/InvenTree/part/templates/part/category_navbar.html b/InvenTree/part/templates/part/category_navbar.html index d84b49a311..bd61300531 100644 --- a/InvenTree/part/templates/part/category_navbar.html +++ b/InvenTree/part/templates/part/category_navbar.html @@ -1,4 +1,7 @@ {% load i18n %} +{% load inventree_extras %} + +{% settings_value 'PART_SHOW_IMPORT' as show_import %}
+{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/import_wizard/match_fields.html b/InvenTree/part/templates/part/import_wizard/match_fields.html new file mode 100644 index 0000000000..ba709bc639 --- /dev/null +++ b/InvenTree/part/templates/part/import_wizard/match_fields.html @@ -0,0 +1,99 @@ +{% extends "part/import_wizard/part_upload.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block form_alert %} +{% if missing_columns and missing_columns|length > 0 %} + +{% endif %} +{% if duplicates and duplicates|length > 0 %} + +{% endif %} +{% endblock form_alert %} + +{% block form_buttons_top %} + {% if wizard.steps.prev %} + + {% endif %} + +{% endblock form_buttons_top %} + +{% block form_content %} + + + {% trans "File Fields" %} + + {% for col in form %} + +
+ + {{ col.name }} + +
+ + {% endfor %} + + + + + {% trans "Match Fields" %} + + {% for col in form %} + + {{ col }} + {% for duplicate in duplicates %} + {% if duplicate == col.value %} + + {% endif %} + {% endfor %} + + {% endfor %} + + {% for row in rows %} + {% with forloop.counter as row_index %} + + + + + {{ row_index }} + {% for item in row.data %} + + + {{ item }} + + {% endfor %} + + {% endwith %} + {% endfor %} + +{% endblock form_content %} + +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} + +{% block js_ready %} +{{ block.super }} + +$('.fieldselect').select2({ + width: '100%', + matcher: partialMatcher, +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/import_wizard/match_references.html b/InvenTree/part/templates/part/import_wizard/match_references.html new file mode 100644 index 0000000000..99b9ccd191 --- /dev/null +++ b/InvenTree/part/templates/part/import_wizard/match_references.html @@ -0,0 +1,91 @@ +{% extends "part/import_wizard/part_upload.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} +{% load crispy_forms_tags %} + +{% block form_alert %} +{% if form.errors %} +{% endif %} +{% if form_errors %} + +{% endif %} +{% endblock form_alert %} + +{% block form_buttons_top %} + {% if wizard.steps.prev %} + + {% endif %} + +{% endblock form_buttons_top %} + +{% block form_content %} + + + + {% trans "Row" %} + {% for col in columns %} + + + + + {% if col.guess %} + {{ col.guess }} + {% else %} + {{ col.name }} + {% endif %} + + {% endfor %} + + + + {% comment %} Dummy row for javascript del_row method {% endcomment %} + {% for row in rows %} + + + + + + {% add row.index 1 %} + + {% for item in row.data %} + + {% if item.column.guess %} + {% with row_name=item.column.guess|lower %} + {% for field in form.visible_fields %} + {% if field.name == row|keyvalue:row_name %} + {{ field|as_crispy_field }} + {% endif %} + {% endfor %} + {% endwith %} + {% else %} + {{ item.cell }} + {% endif %} + + + {% endfor %} + + {% endfor %} + +{% endblock form_content %} + +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} + +{% block js_ready %} +{{ block.super }} + +$('.bomselect').select2({ + dropdownAutoWidth: true, + matcher: partialMatcher, +}); + +$('.currencyselect').select2({ + dropdownAutoWidth: true, +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/import_wizard/part_upload.html b/InvenTree/part/templates/part/import_wizard/part_upload.html new file mode 100644 index 0000000000..87809603bb --- /dev/null +++ b/InvenTree/part/templates/part/import_wizard/part_upload.html @@ -0,0 +1,61 @@ +{% extends "part/category.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block menubar %} +{% include 'part/category_navbar.html' with tab='import' %} +{% endblock %} + +{% block category_content %} +
+
+

+ {% trans "Import Parts from File" %} + {{ wizard.form.media }} +

+
+
+ {% if roles.part.change %} + +

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

+ + {% block form_alert %} + {% endblock form_alert %} + + + {% csrf_token %} + {% load crispy_forms_tags %} + + {% block form_buttons_top %} + {% endblock form_buttons_top %} + + + {{ wizard.management_form }} + {% block form_content %} + {% crispy wizard.form %} + {% endblock form_content %} +
+ + {% block form_buttons_bottom %} + {% if wizard.steps.prev %} + + {% endif %} + + + {% endblock form_buttons_bottom %} + + {% else %} + + {% endif %} +
+
+{% endblock %} + +{% block js_ready %} +{{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/order_prices.html b/InvenTree/part/templates/part/order_prices.html index 5e5552c5f9..c8f0ea29a0 100644 --- a/InvenTree/part/templates/part/order_prices.html +++ b/InvenTree/part/templates/part/order_prices.html @@ -16,6 +16,7 @@ {% default_currency as currency %} {% settings_value "PART_INTERNAL_PRICE" as show_internal_price %} +
{% csrf_token %}
diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index d12612c604..c35786e5d3 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -128,6 +128,10 @@ part_urls = [ # Create a new part url(r'^new/?', views.PartCreate.as_view(), name='part-create'), + # Upload a part + url(r'^import/', views.PartImport.as_view(), name='part-import'), + url(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'), + # Create a new BOM item url(r'^bom/new/?', views.BomItemCreate.as_view(), name='bom-item-create'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 743b3a36b9..2f129dd30b 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -17,6 +17,7 @@ from django.views.generic import DetailView, ListView, FormView, UpdateView from django.forms.models import model_to_dict from django.forms import HiddenInput, CheckboxInput from django.conf import settings +from django.contrib import messages from moneyed import CURRENCIES from djmoney.contrib.exchange.models import convert_money @@ -40,6 +41,10 @@ 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 stock.models import StockLocation import common.settings as inventree_settings @@ -719,6 +724,168 @@ class PartCreate(AjaxCreateView): return initials +class PartImport(FileManagementFormView): + ''' Part: Upload file, match to fields and import parts(using multi-Step form) ''' + permission_required = 'part.add' + + class PartFileManager(FileManager): + REQUIRED_HEADERS = [ + 'Name', + 'Description', + ] + + OPTIONAL_MATCH_HEADERS = [ + 'Category', + 'default_location', + 'default_supplier', + ] + + OPTIONAL_HEADERS = [ + 'Keywords', + 'IPN', + 'Revision', + 'Link', + 'default_expiry', + 'minimum_stock', + 'Units', + 'Notes', + ] + + name = 'part' + form_steps_template = [ + 'part/import_wizard/part_upload.html', + 'part/import_wizard/match_fields.html', + 'part/import_wizard/match_references.html', + ] + form_steps_description = [ + _("Upload File"), + _("Match Fields"), + _("Match References"), + ] + + form_field_map = { + 'name': 'name', + 'description': 'description', + 'keywords': 'keywords', + 'ipn': 'ipn', + 'revision': 'revision', + 'link': 'link', + 'default_expiry': 'default_expiry', + 'minimum_stock': 'minimum_stock', + 'units': 'units', + 'notes': 'notes', + 'category': 'category', + 'default_location': 'default_location', + 'default_supplier': 'default_supplier', + } + file_manager_class = PartFileManager + + def get_field_selection(self): + """ Fill the form fields for step 3 """ + # fetch available elements + self.allowed_items = {} + self.matches = {} + + self.allowed_items['Category'] = PartCategory.objects.all() + self.matches['Category'] = ['name__contains'] + self.allowed_items['default_location'] = StockLocation.objects.all() + self.matches['default_location'] = ['name__contains'] + self.allowed_items['default_supplier'] = SupplierPart.objects.all() + self.matches['default_supplier'] = ['SKU__contains'] + + # setup + self.file_manager.setup() + # collect submitted column indexes + col_ids = {} + for col in self.file_manager.HEADERS: + index = self.get_column_index(col) + if index >= 0: + col_ids[col] = index + + # parse all rows + for row in self.rows: + # check each submitted column + for idx in col_ids: + data = row['data'][col_ids[idx]]['cell'] + + if idx in self.file_manager.OPTIONAL_MATCH_HEADERS: + try: + exact_match = self.allowed_items[idx].get(**{a: data for a in self.matches[idx]}) + except (ValueError, self.allowed_items[idx].model.DoesNotExist, self.allowed_items[idx].model.MultipleObjectsReturned): + exact_match = None + + row['match_options_' + idx] = self.allowed_items[idx] + row['match_' + idx] = exact_match + continue + + # general fields + row[idx.lower()] = data + + def done(self, form_list, **kwargs): + """ Create items """ + items = self.get_clean_items() + + import_done = 0 + import_error = [] + + # Create Part instances + for part_data in items.values(): + + # set related parts + optional_matches = {} + for idx in self.file_manager.OPTIONAL_MATCH_HEADERS: + if idx.lower() in part_data: + try: + optional_matches[idx] = self.allowed_items[idx].get(pk=int(part_data[idx.lower()])) + except (ValueError, self.allowed_items[idx].model.DoesNotExist, self.allowed_items[idx].model.MultipleObjectsReturned): + optional_matches[idx] = None + else: + optional_matches[idx] = None + + # add part + new_part = Part( + name=part_data.get('name', ''), + description=part_data.get('description', ''), + keywords=part_data.get('keywords', None), + IPN=part_data.get('ipn', None), + revision=part_data.get('revision', None), + link=part_data.get('link', None), + default_expiry=part_data.get('default_expiry', 0), + minimum_stock=part_data.get('minimum_stock', 0), + units=part_data.get('units', None), + notes=part_data.get('notes', None), + category=optional_matches['Category'], + default_location=optional_matches['default_location'], + default_supplier=optional_matches['default_supplier'], + ) + try: + new_part.save() + import_done += 1 + except ValidationError as _e: + import_error.append(', '.join(set(_e.messages))) + + # Set alerts + if import_done: + alert = f"{_('Part-Import')}
{_('Imported {n} parts').format(n=import_done)}" + messages.success(self.request, alert) + if import_error: + error_text = '\n'.join([f'
  • x{import_error.count(a)}: {a}
  • ' for a in set(import_error)]) + messages.error(self.request, f"{_('Some errors occured:')}
      {error_text}
    ") + + return HttpResponseRedirect(reverse('part-index')) + + +class PartImportAjax(FileManagementAjaxView, PartImport): + ajax_form_steps_template = [ + 'part/import_wizard/ajax_part_upload.html', + 'part/import_wizard/ajax_match_fields.html', + 'part/import_wizard/ajax_match_references.html', + ] + + def validate(self, obj, form, **kwargs): + return PartImport.validate(self, self.steps.current, form, **kwargs) + + class PartNotes(UpdateView): """ View for editing the 'notes' field of a Part object. Presents a live markdown editor. diff --git a/InvenTree/templates/InvenTree/settings/part.html b/InvenTree/templates/InvenTree/settings/part.html index 1bf5f8794a..eb9fa8ff51 100644 --- a/InvenTree/templates/InvenTree/settings/part.html +++ b/InvenTree/templates/InvenTree/settings/part.html @@ -41,6 +41,22 @@ +

    {% trans "Part Import" %}

    + + + + + + {% include "InvenTree/settings/header.html" %} + + {% include "InvenTree/settings/setting.html" with key="PART_SHOW_IMPORT" icon="fa-file-upload" %} + +
    + + +

    {% trans "Part Parameter Templates" %}

    @@ -125,4 +141,8 @@ }); }); + $("#import-part").click(function() { + launchModalForm("{% url 'api-part-import' %}?reset", {}); + }); + {% endblock %} \ No newline at end of file