mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into bom-pricing
This commit is contained in:
commit
cd6d13fa7d
@ -18,6 +18,7 @@ class InvenTreeAPITestCase(APITestCase):
|
||||
email = 'test@testing.com'
|
||||
|
||||
superuser = False
|
||||
is_staff = True
|
||||
auto_login = True
|
||||
|
||||
# Set list of roles automatically associated with the user
|
||||
@ -40,8 +41,12 @@ class InvenTreeAPITestCase(APITestCase):
|
||||
|
||||
if self.superuser:
|
||||
self.user.is_superuser = True
|
||||
self.user.save()
|
||||
|
||||
if self.is_staff:
|
||||
self.user.is_staff = True
|
||||
|
||||
self.user.save()
|
||||
|
||||
for role in self.roles:
|
||||
self.assignRole(role)
|
||||
|
||||
|
@ -2,17 +2,19 @@
|
||||
Serializers used in various InvenTree apps
|
||||
"""
|
||||
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.utils import model_meta
|
||||
from rest_framework.fields import empty
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
@ -42,6 +44,72 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
||||
but also ensures that the underlying model class data are checked on validation.
|
||||
"""
|
||||
|
||||
def __init__(self, instance=None, data=empty, **kwargs):
|
||||
|
||||
self.instance = instance
|
||||
|
||||
# If instance is None, we are creating a new instance
|
||||
if instance is None:
|
||||
|
||||
if data is empty:
|
||||
data = OrderedDict()
|
||||
else:
|
||||
# Required to side-step immutability of a QueryDict
|
||||
data = data.copy()
|
||||
|
||||
# Add missing fields which have default values
|
||||
ModelClass = self.Meta.model
|
||||
|
||||
fields = model_meta.get_field_info(ModelClass)
|
||||
|
||||
for field_name, field in fields.fields.items():
|
||||
|
||||
if field.has_default() and field_name not in data:
|
||||
|
||||
value = field.default
|
||||
|
||||
# Account for callable functions
|
||||
if callable(value):
|
||||
try:
|
||||
value = value()
|
||||
except:
|
||||
continue
|
||||
|
||||
data[field_name] = value
|
||||
|
||||
super().__init__(instance, data, **kwargs)
|
||||
|
||||
def get_initial(self):
|
||||
"""
|
||||
Construct initial data for the serializer.
|
||||
Use the 'default' values specified by the django model definition
|
||||
"""
|
||||
|
||||
initials = super().get_initial()
|
||||
|
||||
# Are we creating a new instance?
|
||||
if self.instance is None:
|
||||
ModelClass = self.Meta.model
|
||||
|
||||
fields = model_meta.get_field_info(ModelClass)
|
||||
|
||||
for field_name, field in fields.fields.items():
|
||||
|
||||
if field.has_default() and field_name not in initials:
|
||||
|
||||
value = field.default
|
||||
|
||||
# Account for callable functions
|
||||
if callable(value):
|
||||
try:
|
||||
value = value()
|
||||
except:
|
||||
continue
|
||||
|
||||
initials[field_name] = value
|
||||
|
||||
return initials
|
||||
|
||||
def run_validation(self, data=empty):
|
||||
""" Perform serializer validation.
|
||||
In addition to running validators on the serializer fields,
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -718,7 +718,7 @@
|
||||
position:relative;
|
||||
height: auto !important;
|
||||
max-height: calc(100vh - 200px) !important;
|
||||
overflow-y: scroll;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
|
@ -8,7 +8,7 @@ import re
|
||||
|
||||
import common.models
|
||||
|
||||
INVENTREE_SW_VERSION = "0.2.4 pre"
|
||||
INVENTREE_SW_VERSION = "0.2.5 pre"
|
||||
|
||||
INVENTREE_API_VERSION = 6
|
||||
|
||||
|
@ -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 = []
|
||||
|
@ -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
|
||||
|
@ -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'),
|
||||
|
@ -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!')
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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 @@
|
||||
<td>
|
||||
{% 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 %}
|
||||
|
@ -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():
|
||||
|
@ -8,6 +8,15 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class='{{ message.tags }}'>
|
||||
{{ message|safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
|
@ -1,4 +1,7 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% settings_value 'PART_SHOW_IMPORT' as show_import %}
|
||||
|
||||
<ul class='list-group'>
|
||||
|
||||
@ -30,6 +33,15 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% if show_import and user.is_staff and roles.part.add %}
|
||||
<li class='list-group-item {% if tab == "import" %}active{% endif %}' title='{% trans "Import Parts" %}'>
|
||||
<a href='{% url "part-import" %}'>
|
||||
<span class='fas fa-file-upload sidebar-icon'></span>
|
||||
{% trans "Import Parts" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if category %}
|
||||
<li class='list-group-item {% if tab == "parameters" %}active{% endif %}' title='{% trans "Parameters" %}'>
|
||||
<a href='{% url "category-parametric" category.id %}'>
|
||||
|
@ -0,0 +1,89 @@
|
||||
{% extends "part/import_wizard/ajax_part_upload.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block form_alert %}
|
||||
{% if missing_columns and missing_columns|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Missing selections for the following required columns" %}:
|
||||
<br>
|
||||
<ul>
|
||||
{% for col in missing_columns %}
|
||||
<li>{{ col }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if duplicates and duplicates|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
{% block form_content %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "File Fields" %}</th>
|
||||
<th></th>
|
||||
{% for col in form %}
|
||||
<th>
|
||||
<div>
|
||||
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||
{{ col.name }}
|
||||
<button class='btn btn-default btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'>
|
||||
<span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{% trans "Match Fields" %}</td>
|
||||
<td></td>
|
||||
{% for col in form %}
|
||||
<td>
|
||||
{{ col }}
|
||||
{% for duplicate in duplicates %}
|
||||
{% if duplicate == col.value %}
|
||||
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
||||
<b>{% trans "Duplicate selection" %}</b>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for row in rows %}
|
||||
{% with forloop.counter as row_index %}
|
||||
<tr>
|
||||
<td style='width: 32px;'>
|
||||
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row_index }}' style='display: inline; float: left;' title='{% trans "Remove row" %}'>
|
||||
<span row_id='{{ row_index }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td style='text-align: left;'>{{ row_index }}</td>
|
||||
{% for item in row.data %}
|
||||
<td>
|
||||
<input type='hidden' name='row_{{ row_index }}_col_{{ forloop.counter0 }}' value='{{ item }}'/>
|
||||
{{ item }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endblock form_content %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('.fieldselect').select2({
|
||||
width: '100%',
|
||||
matcher: partialMatcher,
|
||||
});
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,84 @@
|
||||
{% extends "part/import_wizard/ajax_part_upload.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block form_alert %}
|
||||
{% if form.errors %}
|
||||
{% endif %}
|
||||
{% if form_errors %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Errors exist in the submitted data" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
{% block form_content %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{% trans "Row" %}</th>
|
||||
{% for col in columns %}
|
||||
|
||||
<th>
|
||||
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||
<input type='hidden' name='col_guess_{{ forloop.counter0 }}' value='{{ col.guess }}'/>
|
||||
{% if col.guess %}
|
||||
{{ col.guess }}
|
||||
{% else %}
|
||||
{{ col.name }}
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr></tr> {% comment %} Dummy row for javascript del_row method {% endcomment %}
|
||||
{% for row in rows %}
|
||||
<tr {% if row.errors %} style='background: #ffeaea;'{% endif %} part-select='#select_part_{{ row.index }}'>
|
||||
<td>
|
||||
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row.index }}' style='display: inline; float: right;' title='{% trans "Remove row" %}'>
|
||||
<span row_id='{{ row.index }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{% add row.index 1 %}
|
||||
</td>
|
||||
{% for item in row.data %}
|
||||
<td>
|
||||
{% 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 %}
|
||||
<input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endblock form_content %}
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% endblock form_buttons_bottom %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('.bomselect').select2({
|
||||
dropdownAutoWidth: true,
|
||||
matcher: partialMatcher,
|
||||
});
|
||||
|
||||
$('.currencyselect').select2({
|
||||
dropdownAutoWidth: true,
|
||||
});
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,33 @@
|
||||
{% extends "modal_form.html" %}
|
||||
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block form %}
|
||||
|
||||
{% if roles.part.change %}
|
||||
|
||||
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
|
||||
{% if description %}- {{ description }}{% endif %}</p>
|
||||
|
||||
{% block form_alert %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
|
||||
{{ wizard.management_form }}
|
||||
{% block form_content %}
|
||||
{% crispy wizard.form %}
|
||||
{% endblock form_content %}
|
||||
</table>
|
||||
|
||||
{% else %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Unsuffitient privileges." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
@ -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 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Missing selections for the following required columns" %}:
|
||||
<br>
|
||||
<ul>
|
||||
{% for col in missing_columns %}
|
||||
<li>{{ col }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if duplicates and duplicates|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
{% block form_buttons_top %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
|
||||
{% endblock form_buttons_top %}
|
||||
|
||||
{% block form_content %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "File Fields" %}</th>
|
||||
<th></th>
|
||||
{% for col in form %}
|
||||
<th>
|
||||
<div>
|
||||
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||
{{ col.name }}
|
||||
<button class='btn btn-default btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'>
|
||||
<span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{% trans "Match Fields" %}</td>
|
||||
<td></td>
|
||||
{% for col in form %}
|
||||
<td>
|
||||
{{ col }}
|
||||
{% for duplicate in duplicates %}
|
||||
{% if duplicate == col.value %}
|
||||
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
||||
<b>{% trans "Duplicate selection" %}</b>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for row in rows %}
|
||||
{% with forloop.counter as row_index %}
|
||||
<tr>
|
||||
<td style='width: 32px;'>
|
||||
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row_index }}' style='display: inline; float: left;' title='{% trans "Remove row" %}'>
|
||||
<span row_id='{{ row_index }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td style='text-align: left;'>{{ row_index }}</td>
|
||||
{% for item in row.data %}
|
||||
<td>
|
||||
<input type='hidden' name='row_{{ row_index }}_col_{{ forloop.counter0 }}' value='{{ item }}'/>
|
||||
{{ item }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endblock form_content %}
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% endblock form_buttons_bottom %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('.fieldselect').select2({
|
||||
width: '100%',
|
||||
matcher: partialMatcher,
|
||||
});
|
||||
|
||||
{% endblock %}
|
@ -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 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Errors exist in the submitted data" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
{% block form_buttons_top %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
|
||||
{% endblock form_buttons_top %}
|
||||
|
||||
{% block form_content %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{% trans "Row" %}</th>
|
||||
{% for col in columns %}
|
||||
|
||||
<th>
|
||||
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||
<input type='hidden' name='col_guess_{{ forloop.counter0 }}' value='{{ col.guess }}'/>
|
||||
{% if col.guess %}
|
||||
{{ col.guess }}
|
||||
{% else %}
|
||||
{{ col.name }}
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr></tr> {% comment %} Dummy row for javascript del_row method {% endcomment %}
|
||||
{% for row in rows %}
|
||||
<tr {% if row.errors %} style='background: #ffeaea;'{% endif %} part-select='#select_part_{{ row.index }}'>
|
||||
<td>
|
||||
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row.index }}' style='display: inline; float: right;' title='{% trans "Remove row" %}'>
|
||||
<span row_id='{{ row.index }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{% add row.index 1 %}
|
||||
</td>
|
||||
{% for item in row.data %}
|
||||
<td>
|
||||
{% 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 %}
|
||||
<input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endblock form_content %}
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% endblock form_buttons_bottom %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('.bomselect').select2({
|
||||
dropdownAutoWidth: true,
|
||||
matcher: partialMatcher,
|
||||
});
|
||||
|
||||
$('.currencyselect').select2({
|
||||
dropdownAutoWidth: true,
|
||||
});
|
||||
|
||||
{% endblock %}
|
61
InvenTree/part/templates/part/import_wizard/part_upload.html
Normal file
61
InvenTree/part/templates/part/import_wizard/part_upload.html
Normal file
@ -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 %}
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% trans "Import Parts from File" %}
|
||||
{{ wizard.form.media }}
|
||||
</h4>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if roles.part.change %}
|
||||
|
||||
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
|
||||
{% if description %}- {{ description }}{% endif %}</p>
|
||||
|
||||
{% block form_alert %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block form_buttons_top %}
|
||||
{% endblock form_buttons_top %}
|
||||
|
||||
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
|
||||
{{ wizard.management_form }}
|
||||
{% block form_content %}
|
||||
{% crispy wizard.form %}
|
||||
{% endblock form_content %}
|
||||
</table>
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="save btn btn-default">{% trans "Upload File" %}</button>
|
||||
</form>
|
||||
{% endblock form_buttons_bottom %}
|
||||
|
||||
{% else %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Unsuffitient privileges." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
{% endblock %}
|
@ -16,6 +16,7 @@
|
||||
{% default_currency as currency %}
|
||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||
|
||||
|
||||
<form method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
|
@ -13,6 +13,7 @@ from InvenTree.status_codes import StockStatus
|
||||
from part.models import Part, PartCategory
|
||||
from stock.models import StockItem
|
||||
from company.models import Company
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
|
||||
class PartAPITest(InvenTreeAPITestCase):
|
||||
@ -311,6 +312,59 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
|
||||
self.assertEqual(len(data['results']), n)
|
||||
|
||||
def test_default_values(self):
|
||||
"""
|
||||
Tests for 'default' values:
|
||||
|
||||
Ensure that unspecified fields revert to "default" values
|
||||
(as specified in the model field definition)
|
||||
"""
|
||||
|
||||
url = reverse('api-part-list')
|
||||
|
||||
response = self.client.post(url, {
|
||||
'name': 'all defaults',
|
||||
'description': 'my test part',
|
||||
'category': 1,
|
||||
})
|
||||
|
||||
data = response.data
|
||||
|
||||
# Check that the un-specified fields have used correct default values
|
||||
self.assertTrue(data['active'])
|
||||
self.assertFalse(data['virtual'])
|
||||
|
||||
# By default, parts are not purchaseable
|
||||
self.assertFalse(data['purchaseable'])
|
||||
|
||||
# Set the default 'purchaseable' status to True
|
||||
InvenTreeSetting.set_setting(
|
||||
'PART_PURCHASEABLE',
|
||||
True,
|
||||
self.user
|
||||
)
|
||||
|
||||
response = self.client.post(url, {
|
||||
'name': 'all defaults',
|
||||
'description': 'my test part 2',
|
||||
'category': 1,
|
||||
})
|
||||
|
||||
# Part should now be purchaseable by default
|
||||
self.assertTrue(response.data['purchaseable'])
|
||||
|
||||
# "default" values should not be used if the value is specified
|
||||
response = self.client.post(url, {
|
||||
'name': 'all defaults',
|
||||
'description': 'my test part 2',
|
||||
'category': 1,
|
||||
'active': False,
|
||||
'purchaseable': False,
|
||||
})
|
||||
|
||||
self.assertFalse(response.data['active'])
|
||||
self.assertFalse(response.data['purchaseable'])
|
||||
|
||||
|
||||
class PartDetailTests(InvenTreeAPITestCase):
|
||||
"""
|
||||
@ -391,7 +445,14 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
|
||||
# Try to remove the part
|
||||
response = self.client.delete(url)
|
||||
|
||||
|
||||
# As the part is 'active' we cannot delete it
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
# So, let's make it not active
|
||||
response = self.patch(url, {'active': False}, expected_code=200)
|
||||
|
||||
response = self.client.delete(url)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
||||
# Part count should have reduced
|
||||
@ -542,8 +603,6 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
# And now check that the image has been set
|
||||
p = Part.objects.get(pk=pk)
|
||||
|
||||
print("Image:", p.image.file)
|
||||
|
||||
|
||||
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
"""
|
||||
|
@ -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'),
|
||||
|
||||
|
@ -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"<strong>{_('Part-Import')}</strong><br>{_('Imported {n} parts').format(n=import_done)}"
|
||||
messages.success(self.request, alert)
|
||||
if import_error:
|
||||
error_text = '\n'.join([f'<li><strong>x{import_error.count(a)}</strong>: {a}</li>' for a in set(import_error)])
|
||||
messages.error(self.request, f"<strong>{_('Some errors occured:')}</strong><br><ul>{error_text}</ul>")
|
||||
|
||||
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.
|
||||
|
@ -354,16 +354,18 @@ class StockItemTest(StockAPITestCase):
|
||||
self.assertContains(response, 'does not exist', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# POST without quantity
|
||||
response = self.client.post(
|
||||
response = self.post(
|
||||
self.list_url,
|
||||
data={
|
||||
{
|
||||
'part': 1,
|
||||
'location': 1,
|
||||
}
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.assertContains(response, 'This field is required', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Item should have been created with default quantity
|
||||
self.assertEqual(response.data['quantity'], 1)
|
||||
|
||||
# POST with quantity and part and location
|
||||
response = self.client.post(
|
||||
self.list_url,
|
||||
|
@ -41,6 +41,22 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4>{% trans "Part Import" %}</h4>
|
||||
|
||||
<button class='btn btn-success' id='import-part'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Import Part" %}
|
||||
</button>
|
||||
|
||||
|
||||
<table class='table table-striped table-condensed'>
|
||||
{% include "InvenTree/settings/header.html" %}
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_IMPORT" icon="fa-file-upload" %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
<h4>{% trans "Part Parameter Templates" %}</h4>
|
||||
|
||||
<div id='param-buttons'>
|
||||
@ -125,4 +141,8 @@
|
||||
});
|
||||
});
|
||||
|
||||
$("#import-part").click(function() {
|
||||
launchModalForm("{% url 'api-part-import' %}?reset", {});
|
||||
});
|
||||
|
||||
{% endblock %}
|
Loading…
Reference in New Issue
Block a user