diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index cc61748372..680389272a 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -263,6 +263,7 @@ INSTALLED_APPS = [ 'djmoney.contrib.exchange', # django-money exchange rates 'error_report', # Error reporting in the admin interface 'django_q', + 'formtools', # Form wizard tools ] MIDDLEWARE = CONFIG.get('middleware', [ diff --git a/InvenTree/common/files.py b/InvenTree/common/files.py new file mode 100644 index 0000000000..377120f44d --- /dev/null +++ b/InvenTree/common/files.py @@ -0,0 +1,240 @@ +""" +Files management tools. +""" + +from rapidfuzz import fuzz +import tablib +import os + +from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import ValidationError + +# from company.models import ManufacturerPart, SupplierPart + + +class FileManager: + """ Class for managing an uploaded file """ + + name = '' + + # Fields which are absolutely necessary for valid upload + REQUIRED_HEADERS = [] + + # 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 = [] + + 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 + + # Process initial file + self.process(file) + + # Update headers + self.update_headers() + + @classmethod + def validate(cls, file): + """ Validate file extension and data """ + + cleaned_data = None + + ext = os.path.splitext(file.name)[-1].lower().replace('.', '') + + if ext in ['csv', 'tsv', ]: + # These file formats need string decoding + raw_data = file.read().decode('utf-8') + # Reset stream position to beginning of file + file.seek(0) + elif ext in ['xls', 'xlsx', 'json', 'yaml', ]: + raw_data = file.read() + # Reset stream position to beginning of file + file.seek(0) + else: + raise ValidationError(_(f'Unsupported file format: {ext.upper()}')) + + try: + cleaned_data = tablib.Dataset().load(raw_data, format=ext) + except tablib.UnsupportedFormat: + raise ValidationError(_('Error reading file (invalid format)')) + except tablib.core.InvalidDimensions: + 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 + + def setup(self): + """ Setup headers depending on the file name """ + + 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() + + 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 + """ + + # Try for an exact match + for h in self.HEADERS: + if h == header: + return h + + # Try for a case-insensitive match + for h in self.HEADERS: + if h.lower() == header.lower(): + return h + + # Try for a case-insensitive match with space replacement + for h in self.HEADERS: + if h.lower() == header.lower().replace(' ', '_'): + return h + + # Finally, look for a close match using fuzzy matching + matches = [] + + for h in self.HEADERS: + ratio = fuzz.partial_ratio(header, h) + if ratio > threshold: + matches.append({'header': h, 'match': ratio}) + + if len(matches) > 0: + matches = sorted(matches, key=lambda item: item['match'], reverse=True) + return matches[0]['header'] + + return None + + def columns(self): + """ Return a list of headers for the thingy """ + headers = [] + + for header in self.data.headers: + # Guess header + guess = self.guess_header(header, threshold=95) + # Check if already present + guess_exists = False + for idx, data in enumerate(headers): + if guess == data['guess']: + guess_exists = True + break + + if not guess_exists: + headers.append({ + 'name': header, + 'guess': guess + }) + else: + headers.append({ + 'name': header, + 'guess': None + }) + + return headers + + def col_count(self): + if self.data is None: + return 0 + + return len(self.data.headers) + + def row_count(self): + """ Return the number of rows in the file. """ + + if self.data is None: + return 0 + + return len(self.data) + + def rows(self): + """ Return a list of all rows """ + rows = [] + + for i in range(self.row_count()): + + data = [item for item in self.get_row_data(i)] + + # Is the row completely empty? Skip! + empty = True + + for idx, item in enumerate(data): + if len(str(item).strip()) > 0: + empty = False + + try: + # Excel import casts number-looking-items into floats, which is annoying + if item == int(item) and not str(item) == str(int(item)): + data[idx] = int(item) + except ValueError: + pass + except TypeError: + data[idx] = '' + + # Skip empty rows + if empty: + continue + + row = { + 'data': data, + 'index': i + } + + rows.append(row) + + return rows + + def get_row_data(self, index): + """ Retrieve row data at a particular index """ + if self.data is None or index >= len(self.data): + return None + + return self.data[index] + + def get_row_dict(self, index): + """ Retrieve a dict object representing the data row at a particular offset """ + + if self.data is None or index >= len(self.data): + return None + + return self.data.dict[index] diff --git a/InvenTree/common/forms.py b/InvenTree/common/forms.py index 84e44f3a31..8a0017e38b 100644 --- a/InvenTree/common/forms.py +++ b/InvenTree/common/forms.py @@ -5,8 +5,16 @@ Django forms for interacting with common objects # -*- coding: utf-8 -*- from __future__ import unicode_literals +from decimal import Decimal, InvalidOperation + +from django import forms +from django.utils.translation import gettext as _ + +from djmoney.forms.fields import MoneyField + from InvenTree.forms import HelperForm +from .files import FileManager from .models import InvenTreeSetting @@ -21,3 +29,183 @@ class SettingEditForm(HelperForm): fields = [ 'value' ] + + +class UploadFile(forms.Form): + """ Step 1 of FileManagementFormView """ + + file = forms.FileField( + label=_('File'), + help_text=_('Select file to upload'), + ) + + def __init__(self, *args, **kwargs): + """ Update label and help_text """ + + # Get file name + name = None + if 'name' in kwargs: + name = kwargs.pop('name') + + super().__init__(*args, **kwargs) + + if name: + # Update label and help_text with file name + self.fields['file'].label = _(f'{name.title()} File') + self.fields['file'].help_text = _(f'Select {name} file to upload') + + def clean_file(self): + """ + Run tabular file validation. + If anything is wrong with the file, it will raise ValidationError + """ + + file = self.cleaned_data['file'] + + # Validate file using FileManager class - will perform initial data validation + # (and raise a ValidationError if there is something wrong with the file) + FileManager.validate(file) + + return file + + +class MatchField(forms.Form): + """ Step 2 of FileManagementFormView """ + + def __init__(self, *args, **kwargs): + + # Get FileManager + file_manager = None + if 'file_manager' in kwargs: + file_manager = kwargs.pop('file_manager') + + super().__init__(*args, **kwargs) + + # Setup FileManager + file_manager.setup() + # Get columns + 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'] + self.fields[field_name] = forms.ChoiceField( + choices=[('', '-' * 10)] + headers_choices, + required=False, + widget=forms.Select(attrs={ + 'class': 'select fieldselect', + }) + ) + if col['guess']: + self.fields[field_name].initial = col['guess'] + + +class MatchItem(forms.Form): + """ Step 3 of FileManagementFormView """ + + def __init__(self, *args, **kwargs): + + # Get FileManager + file_manager = None + if 'file_manager' in kwargs: + file_manager = kwargs.pop('file_manager') + + if 'row_data' in kwargs: + row_data = kwargs.pop('row_data') + else: + row_data = None + + super().__init__(*args, **kwargs) + + def clean(number): + """ Clean-up decimal value """ + + # Check if empty + if not number: + return number + + # Check if decimal type + try: + clean_number = Decimal(number) + except InvalidOperation: + clean_number = number + + return clean_number.quantize(Decimal(1)) if clean_number == clean_number.to_integral() else clean_number.normalize() + + # Setup FileManager + file_manager.setup() + + # Create fields + if row_data: + # Navigate row data + for row in row_data: + # Navigate column data + for col in row['data']: + # Get column matching + col_guess = col['column'].get('guess', None) + + # Create input for required headers + if col_guess in file_manager.REQUIRED_HEADERS: + # Set field name + field_name = col_guess.lower() + '-' + str(row['index']) + # 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(row.get('quantity', '')), + }) + ) + + # Create item selection box + elif col_guess in file_manager.ITEM_MATCH_HEADERS: + # Get item options + item_options = [(option.id, option) for option in row['item_options']] + # Get item match + item_match = row['item_match'] + # Set field name + field_name = 'item_select-' + str(row['index']) + # 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: + # Make it a required field: does not validate if + # removed using JS function + # self.fields[field_name].required = True + # Update initial value + self.fields[field_name].initial = item_match.id + + # 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=InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY'), + decimal_places=5, + max_digits=19, + required=False, + default_amount=clean(value), + ) + else: + self.fields[field_name] = forms.CharField( + required=False, + initial=value, + ) diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index 8cc344c9ab..fa605c2b80 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -5,14 +5,21 @@ Django views for interacting with common models # -*- coding: utf-8 -*- from __future__ import unicode_literals +import os + from django.utils.translation import ugettext_lazy as _ from django.forms import CheckboxInput, Select +from django.conf import settings +from django.core.files.storage import FileSystemStorage + +from formtools.wizard.views import SessionWizardView from InvenTree.views import AjaxUpdateView from InvenTree.helpers import str2bool from . import models from . import forms +from .files import FileManager class SettingEdit(AjaxUpdateView): @@ -101,3 +108,392 @@ class SettingEdit(AjaxUpdateView): if not str2bool(value, test=True) and not str2bool(value, test=False): form.add_error('value', _('Supplied value must be a boolean')) + + +class MultiStepFormView(SessionWizardView): + """ Setup basic methods of multi-step form + + form_list: list of forms + form_steps_description: description for each form + """ + + form_list = [] + form_steps_template = [] + form_steps_description = [] + file_manager = None + media_folder = '' + file_storage = FileSystemStorage(settings.MEDIA_ROOT) + + def __init__(self, *args, **kwargs): + """ Override init method to set media folder """ + super().__init__(*args, **kwargs) + + self.process_media_folder() + + def process_media_folder(self): + """ Process media folder """ + + if self.media_folder: + media_folder_abs = os.path.join(settings.MEDIA_ROOT, self.media_folder) + if not os.path.exists(media_folder_abs): + os.mkdir(media_folder_abs) + self.file_storage = FileSystemStorage(location=media_folder_abs) + + def get_template_names(self): + """ Select template """ + + try: + # Get template + template = self.form_steps_template[self.steps.index] + except IndexError: + return self.template_name + + return template + + def get_context_data(self, **kwargs): + """ Update context data """ + + # Retrieve current context + context = super().get_context_data(**kwargs) + + # Get form description + try: + description = self.form_steps_description[self.steps.index] + except IndexError: + description = '' + # Add description to form steps + context.update({'description': description}) + + return context + + +class FileManagementFormView(MultiStepFormView): + """ Setup form wizard to perform the following steps: + 1. Upload tabular data file + 2. Match headers to InvenTree fields + 3. Edit row data and match InvenTree items + """ + + name = None + form_list = [ + ('upload', forms.UploadFile), + ('fields', forms.MatchField), + ('items', forms.MatchItem), + ] + form_steps_description = [ + _("Upload File"), + _("Match Fields"), + _("Match Items"), + ] + media_folder = 'file_upload/' + extra_context_data = {} + + def get_context_data(self, form, **kwargs): + 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() + # Check for stored data + stored_data = self.storage.get_step_data(self.steps.current) + if stored_data: + self.get_form_table_data(stored_data) + 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}) + + # Load extra context data + for key, items in self.extra_context_data.items(): + context.update({key: items}) + + return context + + def get_file_manager(self, step=None, form=None): + """ Get FileManager instance from uploaded file """ + + if self.file_manager: + return + + if step is not None: + # Retrieve stored files from upload step + upload_files = self.storage.get_step_files('upload') + if upload_files: + # Get file + file = upload_files.get('upload-file', None) + if file: + self.file_manager = FileManager(file=file, name=self.name) + + def get_form_kwargs(self, step=None): + """ Update kwargs to dynamically build forms """ + + # Always retrieve FileManager instance from uploaded file + self.get_file_manager(step) + + if step == 'upload': + # Dynamically build upload form + if self.name: + kwargs = { + 'name': self.name + } + return kwargs + elif step == 'fields': + # Dynamically build match field form + kwargs = { + 'file_manager': self.file_manager + } + return kwargs + elif step == 'items': + # Dynamically build match item form + kwargs = {} + kwargs['file_manager'] = self.file_manager + + # Get data from fields step + data = self.storage.get_step_data('fields') + + # Process to update columns and rows + self.rows = self.file_manager.rows() + self.columns = self.file_manager.columns() + 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_table_data(self, form_data): + """ Extract table cell data from form data and fields. + These data are used to maintain state between sessions. + + Table data keys are as follows: + + col_name_ - Column name at idx as provided in the uploaded file + col_guess_ - Column guess at idx as selected + row__col - Cell data as provided in the uploaded file + + """ + + # Map the columns + self.column_names = {} + self.column_selections = {} + + self.row_data = {} + + for item, value in form_data.items(): + + # Column names as passed as col_name_ where idx is an integer + + # Extract the column names + if item.startswith('col_name_'): + try: + col_id = int(item.replace('col_name_', '')) + except ValueError: + continue + + self.column_names[col_id] = value + + # Extract the column selections (in the 'select fields' view) + if item.startswith('fields-'): + + try: + col_name = item.replace('fields-', '') + except ValueError: + continue + + for idx, name in self.column_names.items(): + if name == col_name: + self.column_selections[idx] = value + break + + # Extract the row data + if item.startswith('row_'): + # Item should be of the format row__col_ + s = item.split('_') + + if len(s) < 4: + continue + + # Ignore row/col IDs which are not correct numeric values + try: + row_id = int(s[1]) + col_id = int(s[3]) + except ValueError: + continue + + if row_id not in self.row_data: + self.row_data[row_id] = {} + + self.row_data[row_id][col_id] = value + + def set_form_table_data(self, form=None): + """ Set the form table data """ + + if self.column_names: + # Re-construct the column data + self.columns = [] + + for idx, value in self.column_names.items(): + header = ({ + 'name': value, + 'guess': self.column_selections.get(idx, ''), + }) + self.columns.append(header) + + if self.row_data: + # Re-construct the row data + self.rows = [] + + # Update the row data + for row_idx, row_key in enumerate(sorted(self.row_data.keys())): + row_data = self.row_data[row_key] + + data = [] + + for idx, item in row_data.items(): + column_data = { + '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 + if form and self.steps.current == 'items': + # Find field keys + field_keys = [] + for field in form.fields: + field_key = field.split('-')[0] + if field_key not in field_keys: + field_keys.append(field_key) + + # Populate rows + for row in self.rows: + for field_key in field_keys: + # Map row data to field + row[field_key] = field_key + '-' + str(row['index']) + + def get_column_index(self, name): + """ Return the index of the column with the given name. + It named column is not found, return -1 + """ + + try: + idx = list(self.column_selections.values()).index(name) + except ValueError: + idx = -1 + + return idx + + 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. + + This method is very specific to the type of data found in the file, + therefore overwrite it in the subclass. + """ + pass + + def check_field_selection(self, form): + """ Check field matching """ + + # Are there any missing columns? + missing_columns = [] + + # Check that all required fields are present + for col in self.file_manager.REQUIRED_HEADERS: + if col not in self.column_selections.values(): + missing_columns.append(col) + + # Check that at least one of the part match field is present + part_match_found = False + for col in self.file_manager.ITEM_MATCH_HEADERS: + 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: + missing_columns.append(col) + + # Track any duplicate column selections + duplicates = [] + + for col in self.column_names: + + if col in self.column_selections: + guess = self.column_selections[col] + else: + guess = None + + if guess: + n = list(self.column_selections.values()).count(self.column_selections[col]) + if n > 1: + duplicates.append(col) + + # Store extra context data + self.extra_context_data = { + 'missing_columns': missing_columns, + 'duplicates': duplicates, + } + + # Data validation + valid = not missing_columns and not duplicates + + return valid + + def validate(self, step, form): + """ Validate forms """ + + valid = True + + # Get form table data + self.get_form_table_data(form.data) + + if step == 'fields': + # Validate user form data + valid = self.check_field_selection(form) + + if not valid: + form.add_error(None, _('Fields matching failed')) + + elif step == 'items': + pass + + return valid + + def post(self, request, *args, **kwargs): + """ Perform validations before posting data """ + + wizard_goto_step = self.request.POST.get('wizard_goto_step', None) + + form = self.get_form(data=self.request.POST, files=self.request.FILES) + + form_valid = self.validate(self.steps.current, form) + + if not form_valid and not wizard_goto_step: + # Re-render same step + return self.render(form) + + return super().post(*args, **kwargs) diff --git a/InvenTree/order/templates/order/order_wizard/match_fields.html b/InvenTree/order/templates/order/order_wizard/match_fields.html new file mode 100644 index 0000000000..4ff7b6a963 --- /dev/null +++ b/InvenTree/order/templates/order/order_wizard/match_fields.html @@ -0,0 +1,99 @@ +{% extends "order/order_wizard/po_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.name %} + + {% 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/order/templates/order/order_wizard/match_parts.html b/InvenTree/order/templates/order/order_wizard/match_parts.html new file mode 100644 index 0000000000..f97edff913 --- /dev/null +++ b/InvenTree/order/templates/order/order_wizard/match_parts.html @@ -0,0 +1,125 @@ +{% extends "order/order_wizard/po_upload.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block form_alert %} +{% if form.errors %} +{% endif %} +{% if form_errors %} + +{% endif %} +{% endblock form_alert %} + +{% block form_buttons_top %} + {% if wizard.steps.prev %} + + {% endif %} + +{% endblock form_buttons_top %} + +{% block form_content %} + + + + {% trans "Row" %} + {% trans "Select Supplier Part" %} + {% trans "Quantity" %} + {% for col in columns %} + {% if col.guess != 'Quantity' %} + + + + {% if col.guess %} + {{ col.guess }} + {% else %} + {{ col.name }} + {% endif %} + + {% endif %} + {% endfor %} + + + + {% comment %} Dummy row for javascript del_row method {% endcomment %} + {% for row in rows %} + + + + + + {% add row.index 1 %} + + + {% for field in form.visible_fields %} + {% if field.name == row.item_select %} + {{ field }} + {% endif %} + {% endfor %} + {% if row.errors.part %} +

{{ row.errors.part }}

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

{{ row.errors.quantity }}

+ {% endif %} + + {% for item in row.data %} + {% if item.column.guess != 'Quantity' %} + + {% if item.column.guess == 'Purchase_Price' %} + {% for field in form.visible_fields %} + {% if field.name == row.purchase_price %} + {{ field }} + {% endif %} + {% endfor %} + {% elif item.column.guess == 'Reference' %} + {% for field in form.visible_fields %} + {% if field.name == row.reference %} + {{ field }} + {% endif %} + {% endfor %} + {% elif item.column.guess == 'Notes' %} + {% for field in form.visible_fields %} + {% if field.name == row.notes %} + {{ field }} + {% endif %} + {% endfor %} + {% else %} + {{ item.cell }} + {% endif %} + + + {% endif %} + {% endfor %} + + {% endfor %} + +{% endblock form_content %} + +{% block form_buttons_bottom %} +{% endblock form_buttons_bottom %} + +{% block js_ready %} +{{ block.super }} + +$('.bomselect').select2({ + dropdownAutoWidth: true, + matcher: partialMatcher, +}); + +$('.currencyselect').select2({ + dropdownAutoWidth: true, +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/order_wizard/po_upload.html b/InvenTree/order/templates/order/order_wizard/po_upload.html new file mode 100644 index 0000000000..a281725173 --- /dev/null +++ b/InvenTree/order/templates/order/order_wizard/po_upload.html @@ -0,0 +1,56 @@ +{% extends "order/order_base.html" %} +{% load inventree_extras %} +{% load i18n %} +{% load static %} + +{% block menubar %} +{% include 'order/po_navbar.html' with tab='upload' %} +{% endblock %} + +{% block heading %} +{% trans "Upload File for Purchase Order" %} +{{ wizard.form.media }} +{% endblock %} + +{% block details %} +{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.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 details %} + +{% block js_ready %} +{{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/po_navbar.html b/InvenTree/order/templates/order/po_navbar.html index eac24d47b3..f8e818c2e3 100644 --- a/InvenTree/order/templates/order/po_navbar.html +++ b/InvenTree/order/templates/order/po_navbar.html @@ -1,6 +1,7 @@ {% load i18n %} {% load static %} {% load inventree_extras %} +{% load status_codes %}