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'
|
email = 'test@testing.com'
|
||||||
|
|
||||||
superuser = False
|
superuser = False
|
||||||
|
is_staff = True
|
||||||
auto_login = True
|
auto_login = True
|
||||||
|
|
||||||
# Set list of roles automatically associated with the user
|
# Set list of roles automatically associated with the user
|
||||||
@ -40,7 +41,11 @@ class InvenTreeAPITestCase(APITestCase):
|
|||||||
|
|
||||||
if self.superuser:
|
if self.superuser:
|
||||||
self.user.is_superuser = True
|
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:
|
for role in self.roles:
|
||||||
self.assignRole(role)
|
self.assignRole(role)
|
||||||
|
@ -2,17 +2,19 @@
|
|||||||
Serializers used in various InvenTree apps
|
Serializers used in various InvenTree apps
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from rest_framework.utils import model_meta
|
||||||
from rest_framework.fields import empty
|
from rest_framework.fields import empty
|
||||||
from rest_framework.exceptions import ValidationError
|
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.
|
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):
|
def run_validation(self, data=empty):
|
||||||
""" Perform serializer validation.
|
""" Perform serializer validation.
|
||||||
In addition to running validators on the serializer fields,
|
In addition to running validators on the serializer fields,
|
||||||
|
@ -23,6 +23,7 @@ import moneyed
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.contrib.messages import constants as messages
|
||||||
|
|
||||||
|
|
||||||
def _is_true(x):
|
def _is_true(x):
|
||||||
@ -611,3 +612,9 @@ IMPORT_EXPORT_USE_TRANSACTIONS = True
|
|||||||
INTERNAL_IPS = [
|
INTERNAL_IPS = [
|
||||||
'127.0.0.1',
|
'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;
|
position:relative;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
max-height: calc(100vh - 200px) !important;
|
max-height: calc(100vh - 200px) !important;
|
||||||
overflow-y: scroll;
|
overflow-y: auto;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ import re
|
|||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
|
|
||||||
INVENTREE_SW_VERSION = "0.2.4 pre"
|
INVENTREE_SW_VERSION = "0.2.5 pre"
|
||||||
|
|
||||||
INVENTREE_API_VERSION = 6
|
INVENTREE_API_VERSION = 6
|
||||||
|
|
||||||
|
@ -26,6 +26,8 @@ class FileManager:
|
|||||||
# Fields which would be helpful but are not required
|
# Fields which would be helpful but are not required
|
||||||
OPTIONAL_HEADERS = []
|
OPTIONAL_HEADERS = []
|
||||||
|
|
||||||
|
OPTIONAL_MATCH_HEADERS = []
|
||||||
|
|
||||||
EDITABLE_HEADERS = []
|
EDITABLE_HEADERS = []
|
||||||
|
|
||||||
HEADERS = []
|
HEADERS = []
|
||||||
@ -82,32 +84,19 @@ class FileManager:
|
|||||||
def update_headers(self):
|
def update_headers(self):
|
||||||
""" Update headers """
|
""" 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):
|
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:
|
if not self.name:
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.name == 'order':
|
# Update headers
|
||||||
self.REQUIRED_HEADERS = [
|
self.update_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):
|
def guess_header(self, header, threshold=80):
|
||||||
""" Try to match a header (from the file) to a list of known headers
|
""" Try to match a header (from the file) to a list of known headers
|
||||||
|
@ -8,12 +8,7 @@ from __future__ import unicode_literals
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from djmoney.forms.fields import MoneyField
|
|
||||||
|
|
||||||
from InvenTree.forms import HelperForm
|
from InvenTree.forms import HelperForm
|
||||||
from InvenTree.helpers import clean_decimal
|
|
||||||
|
|
||||||
from common.settings import currency_code_default
|
|
||||||
|
|
||||||
from .files import FileManager
|
from .files import FileManager
|
||||||
from .models import InvenTreeSetting
|
from .models import InvenTreeSetting
|
||||||
@ -32,7 +27,7 @@ class SettingEditForm(HelperForm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class UploadFile(forms.Form):
|
class UploadFileForm(forms.Form):
|
||||||
""" Step 1 of FileManagementFormView """
|
""" Step 1 of FileManagementFormView """
|
||||||
|
|
||||||
file = forms.FileField(
|
file = forms.FileField(
|
||||||
@ -70,7 +65,7 @@ class UploadFile(forms.Form):
|
|||||||
return file
|
return file
|
||||||
|
|
||||||
|
|
||||||
class MatchField(forms.Form):
|
class MatchFieldForm(forms.Form):
|
||||||
""" Step 2 of FileManagementFormView """
|
""" Step 2 of FileManagementFormView """
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@ -103,7 +98,7 @@ class MatchField(forms.Form):
|
|||||||
self.fields[field_name].initial = col['guess']
|
self.fields[field_name].initial = col['guess']
|
||||||
|
|
||||||
|
|
||||||
class MatchItem(forms.Form):
|
class MatchItemForm(forms.Form):
|
||||||
""" Step 3 of FileManagementFormView """
|
""" Step 3 of FileManagementFormView """
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@ -131,24 +126,41 @@ class MatchItem(forms.Form):
|
|||||||
for col in row['data']:
|
for col in row['data']:
|
||||||
# Get column matching
|
# Get column matching
|
||||||
col_guess = col['column'].get('guess', None)
|
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
|
# Create input for required headers
|
||||||
if col_guess in file_manager.REQUIRED_HEADERS:
|
elif col_guess in file_manager.REQUIRED_HEADERS:
|
||||||
# Set field name
|
# Get value
|
||||||
field_name = col_guess.lower() + '-' + str(row['index'])
|
value = row.get(col_guess.lower(), '')
|
||||||
# Set field input box
|
# Set field input box
|
||||||
if 'quantity' in col_guess.lower():
|
self.fields[field_name] = forms.CharField(
|
||||||
self.fields[field_name] = forms.CharField(
|
required=True,
|
||||||
required=False,
|
initial=value,
|
||||||
widget=forms.NumberInput(attrs={
|
)
|
||||||
'name': 'quantity' + str(row['index']),
|
|
||||||
'class': 'numberinput', # form-control',
|
# Create item selection box
|
||||||
'type': 'number',
|
elif col_guess in file_manager.OPTIONAL_MATCH_HEADERS:
|
||||||
'min': '0',
|
# Get item options
|
||||||
'step': 'any',
|
item_options = [(option.id, option) for option in row['match_options_' + col_guess]]
|
||||||
'value': clean_decimal(row.get('quantity', '')),
|
# 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
|
# Create item selection box
|
||||||
elif col_guess in file_manager.ITEM_MATCH_HEADERS:
|
elif col_guess in file_manager.ITEM_MATCH_HEADERS:
|
||||||
@ -176,22 +188,15 @@ class MatchItem(forms.Form):
|
|||||||
|
|
||||||
# Optional entries
|
# Optional entries
|
||||||
elif col_guess in file_manager.OPTIONAL_HEADERS:
|
elif col_guess in file_manager.OPTIONAL_HEADERS:
|
||||||
# Set field name
|
|
||||||
field_name = col_guess.lower() + '-' + str(row['index'])
|
|
||||||
# Get value
|
# Get value
|
||||||
value = row.get(col_guess.lower(), '')
|
value = row.get(col_guess.lower(), '')
|
||||||
# Set field input box
|
# Set field input box
|
||||||
if 'price' in col_guess.lower():
|
self.fields[field_name] = forms.CharField(
|
||||||
self.fields[field_name] = MoneyField(
|
required=False,
|
||||||
label=_(col_guess),
|
initial=value,
|
||||||
default_currency=currency_code_default(),
|
)
|
||||||
decimal_places=5,
|
|
||||||
max_digits=19,
|
def get_special_field(self, col_guess, row, file_manager):
|
||||||
required=False,
|
""" Function to be overriden in inherited forms to add specific form settings """
|
||||||
default_amount=clean_decimal(value),
|
|
||||||
)
|
return None
|
||||||
else:
|
|
||||||
self.fields[field_name] = forms.CharField(
|
|
||||||
required=False,
|
|
||||||
initial=value,
|
|
||||||
)
|
|
||||||
|
@ -205,6 +205,13 @@ class InvenTreeSetting(models.Model):
|
|||||||
'validator': bool,
|
'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': {
|
'PART_SHOW_PRICE_IN_FORMS': {
|
||||||
'name': _('Show Price in Forms'),
|
'name': _('Show Price in Forms'),
|
||||||
'description': _('Display part price in some 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 django.core.files.storage import FileSystemStorage
|
||||||
|
|
||||||
from formtools.wizard.views import SessionWizardView
|
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 InvenTree.helpers import str2bool
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
@ -117,7 +118,6 @@ class MultiStepFormView(SessionWizardView):
|
|||||||
form_steps_description: description for each form
|
form_steps_description: description for each form
|
||||||
"""
|
"""
|
||||||
|
|
||||||
form_list = []
|
|
||||||
form_steps_template = []
|
form_steps_template = []
|
||||||
form_steps_description = []
|
form_steps_description = []
|
||||||
file_manager = None
|
file_manager = None
|
||||||
@ -126,7 +126,7 @@ class MultiStepFormView(SessionWizardView):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
""" Override init method to set media folder """
|
""" Override init method to set media folder """
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
self.process_media_folder()
|
self.process_media_folder()
|
||||||
|
|
||||||
@ -176,9 +176,9 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
|
|
||||||
name = None
|
name = None
|
||||||
form_list = [
|
form_list = [
|
||||||
('upload', forms.UploadFile),
|
('upload', forms.UploadFileForm),
|
||||||
('fields', forms.MatchField),
|
('fields', forms.MatchFieldForm),
|
||||||
('items', forms.MatchItem),
|
('items', forms.MatchItemForm),
|
||||||
]
|
]
|
||||||
form_steps_description = [
|
form_steps_description = [
|
||||||
_("Upload File"),
|
_("Upload File"),
|
||||||
@ -188,7 +188,22 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
media_folder = 'file_upload/'
|
media_folder = 'file_upload/'
|
||||||
extra_context_data = {}
|
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)
|
context = super().get_context_data(form=form, **kwargs)
|
||||||
|
|
||||||
if self.steps.current in ('fields', 'items'):
|
if self.steps.current in ('fields', 'items'):
|
||||||
@ -227,7 +242,7 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
# Get file
|
# Get file
|
||||||
file = upload_files.get('upload-file', None)
|
file = upload_files.get('upload-file', None)
|
||||||
if file:
|
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):
|
def get_form_kwargs(self, step=None):
|
||||||
""" Update kwargs to dynamically build forms """
|
""" Update kwargs to dynamically build forms """
|
||||||
@ -269,6 +284,15 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
|
|
||||||
return super().get_form_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):
|
def get_form_table_data(self, form_data):
|
||||||
""" Extract table cell data from form data and fields.
|
""" Extract table cell data from form data and fields.
|
||||||
These data are used to maintain state between sessions.
|
These data are used to maintain state between sessions.
|
||||||
@ -375,6 +399,7 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
'data': data,
|
'data': data,
|
||||||
'errors': {},
|
'errors': {},
|
||||||
}
|
}
|
||||||
|
|
||||||
self.rows.append(row)
|
self.rows.append(row)
|
||||||
|
|
||||||
# In the item selection step: update row data with mapping to form fields
|
# In the item selection step: update row data with mapping to form fields
|
||||||
@ -414,6 +439,33 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
"""
|
"""
|
||||||
pass
|
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):
|
def check_field_selection(self, form):
|
||||||
""" Check field matching """
|
""" Check field matching """
|
||||||
|
|
||||||
@ -497,3 +549,70 @@ class FileManagementFormView(MultiStepFormView):
|
|||||||
return self.render(form)
|
return self.render(form)
|
||||||
|
|
||||||
return super().post(*args, **kwargs)
|
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 mptt.fields import TreeNodeChoiceField
|
||||||
|
|
||||||
|
from djmoney.forms.fields import MoneyField
|
||||||
|
|
||||||
from InvenTree.forms import HelperForm
|
from InvenTree.forms import HelperForm
|
||||||
from InvenTree.fields import RoundingDecimalFormField
|
from InvenTree.fields import RoundingDecimalFormField
|
||||||
from InvenTree.fields import DatePickerFormField
|
from InvenTree.fields import DatePickerFormField
|
||||||
|
|
||||||
|
from InvenTree.helpers import clean_decimal
|
||||||
|
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
from common.forms import MatchItemForm
|
||||||
|
|
||||||
import part.models
|
import part.models
|
||||||
|
|
||||||
from stock.models import StockLocation
|
from stock.models import StockLocation
|
||||||
@ -291,3 +298,37 @@ class EditSalesOrderAllocationForm(HelperForm):
|
|||||||
'line',
|
'line',
|
||||||
'item',
|
'item',
|
||||||
'quantity']
|
'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 company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
from stock.serializers import LocationBriefSerializer
|
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
|
||||||
from stock.serializers import StockItemSerializer, LocationSerializer
|
|
||||||
|
|
||||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||||
from .models import PurchaseOrderAttachment, SalesOrderAttachment
|
from .models import PurchaseOrderAttachment, SalesOrderAttachment
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
{% block form_alert %}
|
{% block form_alert %}
|
||||||
{% if form.errors %}
|
{% if form.errors %}
|
||||||
@ -67,7 +68,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{% for field in form.visible_fields %}
|
{% for field in form.visible_fields %}
|
||||||
{% if field.name == row.quantity %}
|
{% if field.name == row.quantity %}
|
||||||
{{ field }}
|
{{ field|as_crispy_field }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if row.errors.quantity %}
|
{% if row.errors.quantity %}
|
||||||
@ -80,19 +81,19 @@
|
|||||||
{% if item.column.guess == 'Purchase_Price' %}
|
{% if item.column.guess == 'Purchase_Price' %}
|
||||||
{% for field in form.visible_fields %}
|
{% for field in form.visible_fields %}
|
||||||
{% if field.name == row.purchase_price %}
|
{% if field.name == row.purchase_price %}
|
||||||
{{ field }}
|
{{ field|as_crispy_field }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% elif item.column.guess == 'Reference' %}
|
{% elif item.column.guess == 'Reference' %}
|
||||||
{% for field in form.visible_fields %}
|
{% for field in form.visible_fields %}
|
||||||
{% if field.name == row.reference %}
|
{% if field.name == row.reference %}
|
||||||
{{ field }}
|
{{ field|as_crispy_field }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% elif item.column.guess == 'Notes' %}
|
{% elif item.column.guess == 'Notes' %}
|
||||||
{% for field in form.visible_fields %}
|
{% for field in form.visible_fields %}
|
||||||
{% if field.name == row.notes %}
|
{% if field.name == row.notes %}
|
||||||
{{ field }}
|
{{ field|as_crispy_field }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -30,7 +30,9 @@ from stock.models import StockItem, StockLocation
|
|||||||
from part.models import Part
|
from part.models import Part
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
|
from common.forms import UploadFileForm, MatchFieldForm
|
||||||
from common.views import FileManagementFormView
|
from common.views import FileManagementFormView
|
||||||
|
from common.files import FileManager
|
||||||
|
|
||||||
from . import forms as order_forms
|
from . import forms as order_forms
|
||||||
from part.views import PartPricing
|
from part.views import PartPricing
|
||||||
@ -572,7 +574,28 @@ class SalesOrderShip(AjaxUpdateView):
|
|||||||
class PurchaseOrderUpload(FileManagementFormView):
|
class PurchaseOrderUpload(FileManagementFormView):
|
||||||
''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) '''
|
''' 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'
|
name = 'order'
|
||||||
|
form_list = [
|
||||||
|
('upload', UploadFileForm),
|
||||||
|
('fields', MatchFieldForm),
|
||||||
|
('items', order_forms.OrderMatchItemForm),
|
||||||
|
]
|
||||||
form_steps_template = [
|
form_steps_template = [
|
||||||
'order/order_wizard/po_upload.html',
|
'order/order_wizard/po_upload.html',
|
||||||
'order/order_wizard/match_fields.html',
|
'order/order_wizard/match_fields.html',
|
||||||
@ -583,7 +606,6 @@ class PurchaseOrderUpload(FileManagementFormView):
|
|||||||
_("Match Fields"),
|
_("Match Fields"),
|
||||||
_("Match Supplier Parts"),
|
_("Match Supplier Parts"),
|
||||||
]
|
]
|
||||||
# Form field name: PurchaseOrderLineItem field
|
|
||||||
form_field_map = {
|
form_field_map = {
|
||||||
'item_select': 'part',
|
'item_select': 'part',
|
||||||
'quantity': 'quantity',
|
'quantity': 'quantity',
|
||||||
@ -591,6 +613,7 @@ class PurchaseOrderUpload(FileManagementFormView):
|
|||||||
'reference': 'reference',
|
'reference': 'reference',
|
||||||
'notes': 'notes',
|
'notes': 'notes',
|
||||||
}
|
}
|
||||||
|
file_manager_class = OrderFileManager
|
||||||
|
|
||||||
def get_order(self):
|
def get_order(self):
|
||||||
""" Get order or return 404 """
|
""" Get order or return 404 """
|
||||||
@ -598,6 +621,8 @@ class PurchaseOrderUpload(FileManagementFormView):
|
|||||||
return get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
|
return get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
|
||||||
|
|
||||||
def get_context_data(self, form, **kwargs):
|
def get_context_data(self, form, **kwargs):
|
||||||
|
""" Handle context data for order """
|
||||||
|
|
||||||
context = super().get_context_data(form=form, **kwargs)
|
context = super().get_context_data(form=form, **kwargs)
|
||||||
|
|
||||||
order = self.get_order()
|
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 """
|
""" Once all the data is in, process it to add PurchaseOrderLineItem instances to the order """
|
||||||
|
|
||||||
order = self.get_order()
|
order = self.get_order()
|
||||||
|
items = self.get_clean_items()
|
||||||
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
|
|
||||||
|
|
||||||
# Create PurchaseOrderLineItem instances
|
# Create PurchaseOrderLineItem instances
|
||||||
for purchase_order_item in items.values():
|
for purchase_order_item in items.values():
|
||||||
|
@ -8,6 +8,15 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% 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='panel panel-default panel-inventree'>
|
||||||
<div class='row'>
|
<div class='row'>
|
||||||
<div class='col-sm-6'>
|
<div class='col-sm-6'>
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
{% settings_value 'PART_SHOW_IMPORT' as show_import %}
|
||||||
|
|
||||||
<ul class='list-group'>
|
<ul class='list-group'>
|
||||||
|
|
||||||
@ -30,6 +33,15 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</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 %}
|
{% if category %}
|
||||||
<li class='list-group-item {% if tab == "parameters" %}active{% endif %}' title='{% trans "Parameters" %}'>
|
<li class='list-group-item {% if tab == "parameters" %}active{% endif %}' title='{% trans "Parameters" %}'>
|
||||||
<a href='{% url "category-parametric" category.id %}'>
|
<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 %}
|
{% default_currency as currency %}
|
||||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||||
|
|
||||||
|
|
||||||
<form method="post" class="form-horizontal">
|
<form method="post" class="form-horizontal">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
@ -13,6 +13,7 @@ from InvenTree.status_codes import StockStatus
|
|||||||
from part.models import Part, PartCategory
|
from part.models import Part, PartCategory
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
from company.models import Company
|
from company.models import Company
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
|
|
||||||
class PartAPITest(InvenTreeAPITestCase):
|
class PartAPITest(InvenTreeAPITestCase):
|
||||||
@ -311,6 +312,59 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
self.assertEqual(len(data['results']), n)
|
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):
|
class PartDetailTests(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
@ -392,6 +446,13 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
# Try to remove the part
|
# Try to remove the part
|
||||||
response = self.client.delete(url)
|
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)
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
# Part count should have reduced
|
# Part count should have reduced
|
||||||
@ -542,8 +603,6 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
# And now check that the image has been set
|
# And now check that the image has been set
|
||||||
p = Part.objects.get(pk=pk)
|
p = Part.objects.get(pk=pk)
|
||||||
|
|
||||||
print("Image:", p.image.file)
|
|
||||||
|
|
||||||
|
|
||||||
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
|
@ -128,6 +128,10 @@ part_urls = [
|
|||||||
# Create a new part
|
# Create a new part
|
||||||
url(r'^new/?', views.PartCreate.as_view(), name='part-create'),
|
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
|
# Create a new BOM item
|
||||||
url(r'^bom/new/?', views.BomItemCreate.as_view(), name='bom-item-create'),
|
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.models import model_to_dict
|
||||||
from django.forms import HiddenInput, CheckboxInput
|
from django.forms import HiddenInput, CheckboxInput
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
|
|
||||||
from moneyed import CURRENCIES
|
from moneyed import CURRENCIES
|
||||||
from djmoney.contrib.exchange.models import convert_money
|
from djmoney.contrib.exchange.models import convert_money
|
||||||
@ -40,6 +41,10 @@ from .models import PartSellPriceBreak, PartInternalPriceBreak
|
|||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from company.models import SupplierPart
|
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
|
import common.settings as inventree_settings
|
||||||
|
|
||||||
@ -719,6 +724,168 @@ class PartCreate(AjaxCreateView):
|
|||||||
return initials
|
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):
|
class PartNotes(UpdateView):
|
||||||
""" View for editing the 'notes' field of a Part object.
|
""" View for editing the 'notes' field of a Part object.
|
||||||
Presents a live markdown editor.
|
Presents a live markdown editor.
|
||||||
|
@ -354,15 +354,17 @@ class StockItemTest(StockAPITestCase):
|
|||||||
self.assertContains(response, 'does not exist', status_code=status.HTTP_400_BAD_REQUEST)
|
self.assertContains(response, 'does not exist', status_code=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# POST without quantity
|
# POST without quantity
|
||||||
response = self.client.post(
|
response = self.post(
|
||||||
self.list_url,
|
self.list_url,
|
||||||
data={
|
{
|
||||||
'part': 1,
|
'part': 1,
|
||||||
'location': 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
|
# POST with quantity and part and location
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
|
@ -41,6 +41,22 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
<h4>{% trans "Part Parameter Templates" %}</h4>
|
||||||
|
|
||||||
<div id='param-buttons'>
|
<div id='param-buttons'>
|
||||||
@ -125,4 +141,8 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("#import-part").click(function() {
|
||||||
|
launchModalForm("{% url 'api-part-import' %}?reset", {});
|
||||||
|
});
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
Loading…
Reference in New Issue
Block a user