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 @@
{% 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 %} + +{% 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 %} + + + {% endblock form_buttons_bottom %} + + {% else %} +