Merge remote-tracking branch 'inventree/master' into drf-api-forms

This commit is contained in:
Oliver 2021-06-27 09:26:23 +10:00
commit 67f76c8bca
43 changed files with 10592 additions and 8151 deletions

View File

@ -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,6 +41,10 @@ class InvenTreeAPITestCase(APITestCase):
if self.superuser: if self.superuser:
self.user.is_superuser = True self.user.is_superuser = True
if self.is_staff:
self.user.is_staff = True
self.user.save() self.user.save()
for role in self.roles: for role in self.roles:

View File

@ -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,

View File

@ -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):
@ -612,3 +613,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',
}

View File

@ -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;
} }

View File

@ -61,21 +61,21 @@ def is_email_configured():
# Display warning unless in test mode # Display warning unless in test mode
if not settings.TESTING: if not settings.TESTING:
logger.warning("EMAIL_HOST is not configured") logger.debug("EMAIL_HOST is not configured")
if not settings.EMAIL_HOST_USER: if not settings.EMAIL_HOST_USER:
configured = False configured = False
# Display warning unless in test mode # Display warning unless in test mode
if not settings.TESTING: if not settings.TESTING:
logger.warning("EMAIL_HOST_USER is not configured") logger.debug("EMAIL_HOST_USER is not configured")
if not settings.EMAIL_HOST_PASSWORD: if not settings.EMAIL_HOST_PASSWORD:
configured = False configured = False
# Display warning unless in test mode # Display warning unless in test mode
if not settings.TESTING: if not settings.TESTING:
logger.warning("EMAIL_HOST_PASSWORD is not configured") logger.debug("EMAIL_HOST_PASSWORD is not configured")
return configured return configured

View File

@ -77,7 +77,7 @@ class APITests(InvenTreeAPITestCase):
self.assertIn('version', data) self.assertIn('version', data)
self.assertIn('instance', data) self.assertIn('instance', data)
self.assertEquals('InvenTree', data['server']) self.assertEqual('InvenTree', data['server'])
def test_role_view(self): def test_role_view(self):
""" """

View File

@ -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,30 +84,17 @@ 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':
self.REQUIRED_HEADERS = [
'Quantity',
]
self.ITEM_MATCH_HEADERS = [
'Manufacturer_MPN',
'Supplier_SKU',
]
self.OPTIONAL_HEADERS = [
'Purchase_Price',
'Reference',
'Notes',
]
# Update headers # Update headers
self.update_headers() self.update_headers()

View File

@ -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)
# Create input for required headers
if col_guess in file_manager.REQUIRED_HEADERS:
# Set field name # Set field name
field_name = col_guess.lower() + '-' + str(row['index']) 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
elif col_guess in file_manager.REQUIRED_HEADERS:
# Get value
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,
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, required=False,
widget=forms.NumberInput(attrs={ widget=forms.Select(attrs={
'name': 'quantity' + str(row['index']), 'class': 'select bomselect',
'class': 'numberinput', # form-control',
'type': 'number',
'min': '0',
'step': 'any',
'value': clean_decimal(row.get('quantity', '')),
}) })
) )
# 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] = 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( self.fields[field_name] = forms.CharField(
required=False, required=False,
initial=value, 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

View File

@ -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'),

View File

@ -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!')

View File

@ -144,9 +144,14 @@
label: 'image', label: 'image',
method: 'PATCH', method: 'PATCH',
success: function(data, status, xhr) { success: function(data, status, xhr) {
if (data.image) {
$('#company-image').attr('src', data.image);
} else {
location.reload(); location.reload();
} }
} }
}
); );
{% if company.image %} {% if company.image %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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)

View File

@ -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

View File

@ -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 %}

View File

@ -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():

View File

@ -1488,16 +1488,17 @@ class Part(MPTTModel):
return True return True
def get_price_info(self, quantity=1, buy=True, bom=True): def get_price_info(self, quantity=1, buy=True, bom=True, internal=False):
""" Return a simplified pricing string for this part """ Return a simplified pricing string for this part
Args: Args:
quantity: Number of units to calculate price for quantity: Number of units to calculate price for
buy: Include supplier pricing (default = True) buy: Include supplier pricing (default = True)
bom: Include BOM pricing (default = True) bom: Include BOM pricing (default = True)
internal: Include internal pricing (default = False)
""" """
price_range = self.get_price_range(quantity, buy, bom) price_range = self.get_price_range(quantity, buy, bom, internal)
if price_range is None: if price_range is None:
return None return None
@ -1585,9 +1586,10 @@ class Part(MPTTModel):
- Supplier price (if purchased from suppliers) - Supplier price (if purchased from suppliers)
- BOM price (if built from other parts) - BOM price (if built from other parts)
- Internal price (if set for the part)
Returns: Returns:
Minimum of the supplier price or BOM price. If no pricing available, returns None Minimum of the supplier, BOM or internal price. If no pricing available, returns None
""" """
# only get internal price if set and should be used # only get internal price if set and should be used
@ -2536,7 +2538,9 @@ class BomItem(models.Model):
def price_range(self): def price_range(self):
""" Return the price-range for this BOM item. """ """ Return the price-range for this BOM item. """
prange = self.sub_part.get_price_range(self.quantity) # get internal price setting
use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
prange = self.sub_part.get_price_range(self.quantity, intenal=use_internal)
if prange is None: if prange is None:
return prange return prange

View File

@ -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'>

View File

@ -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 %}'>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View 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 %}

View File

@ -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">

View File

@ -244,9 +244,16 @@
label: 'image', label: 'image',
method: 'PATCH', method: 'PATCH',
success: function(data, status, xhr) { success: function(data, status, xhr) {
// If image / thumbnail data present, live update
if (data.image) {
$('#part-image').attr('src', data.image);
} else {
// Otherwise, reload the page
location.reload(); location.reload();
} }
} }
}
); );
$("#show-qr-code").click(function() { $("#show-qr-code").click(function() {

View File

@ -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):
""" """

View File

@ -23,7 +23,7 @@ class TestParams(TestCase):
def test_str(self): def test_str(self):
t1 = PartParameterTemplate.objects.get(pk=1) t1 = PartParameterTemplate.objects.get(pk=1)
self.assertEquals(str(t1), 'Length (mm)') self.assertEqual(str(t1), 'Length (mm)')
p1 = PartParameter.objects.get(pk=1) p1 = PartParameter.objects.get(pk=1)
self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm') self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm')

View File

@ -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'),

View File

@ -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.
@ -847,11 +1014,13 @@ class PartPricingView(PartDetail):
# BOM Information for Pie-Chart # BOM Information for Pie-Chart
if part.has_bom: if part.has_bom:
# get internal price setting
use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
ctx_bom_parts = [] ctx_bom_parts = []
# iterate over all bom-items # iterate over all bom-items
for item in part.bom_items.all(): for item in part.bom_items.all():
ctx_item = {'name': str(item.sub_part)} ctx_item = {'name': str(item.sub_part)}
price, qty = item.sub_part.get_price_range(quantity), item.quantity price, qty = item.sub_part.get_price_range(quantity, internal=use_internal), item.quantity
price_min, price_max = 0, 0 price_min, price_max = 0, 0
if price: # check if price available if price: # check if price available

View File

@ -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(

View File

@ -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 %}

View File

@ -179,27 +179,32 @@ function loadStockTestResultsTable(table, options) {
var match = false; var match = false;
var override = false; var override = false;
// Extract the simplified test key
var key = item.key; var key = item.key;
// Attempt to associate this result with an existing test // Attempt to associate this result with an existing test
tableData.forEach(function(row, index) { for (var idx = 0; idx < tableData.length; idx++) {
var row = tableData[idx];
if (key == row.key) { if (key == row.key) {
item.test_name = row.test_name; item.test_name = row.test_name;
item.required = row.required; item.required = row.required;
match = true;
if (row.result == null) { if (row.result == null) {
item.parent = parent_node; item.parent = parent_node;
tableData[index] = item; tableData[idx] = item;
override = true; override = true;
} else { } else {
item.parent = row.pk; item.parent = row.pk;
} }
match = true;
break;
}
} }
});
// No match could be found // No match could be found
if (!match) { if (!match) {