Merge branch 'master' of https://github.com/inventree/InvenTree into one-pricing-view

This commit is contained in:
Matthias 2021-06-27 14:40:17 +02:00
commit 547f7a8f36
55 changed files with 10732 additions and 8186 deletions

3
.gitignore vendored
View File

@ -35,6 +35,9 @@ local_settings.py
*.backup
*.old
# Files used for testing
dummy_image.*
# Sphinx files
docs/_build

View File

@ -18,6 +18,7 @@ class InvenTreeAPITestCase(APITestCase):
email = 'test@testing.com'
superuser = False
is_staff = True
auto_login = True
# Set list of roles automatically associated with the user
@ -40,8 +41,12 @@ class InvenTreeAPITestCase(APITestCase):
if self.superuser:
self.user.is_superuser = True
self.user.save()
if self.is_staff:
self.user.is_staff = True
self.user.save()
for role in self.roles:
self.assignRole(role)
@ -109,12 +114,12 @@ class InvenTreeAPITestCase(APITestCase):
return response
def patch(self, url, data, expected_code=None):
def patch(self, url, data, files=None, expected_code=None):
"""
Issue a PATCH request
"""
response = self.client.patch(url, data=data, format='json')
response = self.client.patch(url, data=data, files=files, format='json')
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)

View File

@ -12,7 +12,7 @@ def isInTestMode():
return False
def canAppAccessDatabase():
def canAppAccessDatabase(allow_test=False):
"""
Returns True if the apps.py file can access database records.
@ -39,6 +39,10 @@ def canAppAccessDatabase():
'compilemessages',
]
if not allow_test:
# Override for testing mode?
excluded_commands.append('test')
for cmd in excluded_commands:
if cmd in sys.argv:
return False

View File

@ -2,17 +2,19 @@
Serializers used in various InvenTree apps
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
from collections import OrderedDict
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError as DjangoValidationError
from rest_framework import serializers
from rest_framework.utils import model_meta
from rest_framework.fields import empty
from rest_framework.exceptions import ValidationError
@ -42,6 +44,72 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
but also ensures that the underlying model class data are checked on validation.
"""
def __init__(self, instance=None, data=empty, **kwargs):
self.instance = instance
# If instance is None, we are creating a new instance
if instance is None:
if data is empty:
data = OrderedDict()
else:
# Required to side-step immutability of a QueryDict
data = data.copy()
# Add missing fields which have default values
ModelClass = self.Meta.model
fields = model_meta.get_field_info(ModelClass)
for field_name, field in fields.fields.items():
if field.has_default() and field_name not in data:
value = field.default
# Account for callable functions
if callable(value):
try:
value = value()
except:
continue
data[field_name] = value
super().__init__(instance, data, **kwargs)
def get_initial(self):
"""
Construct initial data for the serializer.
Use the 'default' values specified by the django model definition
"""
initials = super().get_initial()
# Are we creating a new instance?
if self.instance is None:
ModelClass = self.Meta.model
fields = model_meta.get_field_info(ModelClass)
for field_name, field in fields.fields.items():
if field.has_default() and field_name not in initials:
value = field.default
# Account for callable functions
if callable(value):
try:
value = value()
except:
continue
initials[field_name] = value
return initials
def run_validation(self, data=empty):
""" Perform serializer validation.
In addition to running validators on the serializer fields,
@ -101,3 +169,17 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
return None
return os.path.join(str(settings.MEDIA_URL), str(value))
class InvenTreeImageSerializerField(serializers.ImageField):
"""
Custom image serializer.
On upload, validate that the file is a valid image file
"""
def to_representation(self, value):
if not value:
return None
return os.path.join(str(settings.MEDIA_URL), str(value))

View File

@ -23,6 +23,7 @@ import moneyed
import yaml
from django.utils.translation import gettext_lazy as _
from django.contrib.messages import constants as messages
def _is_true(x):
@ -611,3 +612,9 @@ IMPORT_EXPORT_USE_TRANSACTIONS = True
INTERNAL_IPS = [
'127.0.0.1',
]
MESSAGE_TAGS = {
messages.SUCCESS: 'alert alert-block alert-success',
messages.ERROR: 'alert alert-block alert-danger',
messages.INFO: 'alert alert-block alert-info',
}

View File

@ -718,7 +718,7 @@
position:relative;
height: auto !important;
max-height: calc(100vh - 200px) !important;
overflow-y: scroll;
overflow-y: auto;
padding: 10px;
}

View File

@ -58,7 +58,7 @@ function inventreeFormDataUpload(url, data, options={}) {
xhr.setRequestHeader('X-CSRFToken', csrftoken);
},
url: url,
method: 'POST',
method: options.method || 'POST',
data: data,
processData: false,
contentType: false,

View File

@ -219,6 +219,7 @@ function enableDragAndDrop(element, url, options) {
data - Other form data to upload
success - Callback function in case of success
error - Callback function in case of error
method - HTTP method
*/
data = options.data || {};
@ -254,7 +255,8 @@ function enableDragAndDrop(element, url, options) {
if (options.error) {
options.error(xhr, status, error);
}
}
},
method: options.method || 'POST',
}
);
} else {

View File

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

View File

@ -28,7 +28,7 @@ def schedule_task(taskname, **kwargs):
try:
from django_q.models import Schedule
except (AppRegistryNotReady):
logger.warning("Could not start background tasks - App registry not ready")
logger.info("Could not start background tasks - App registry not ready")
return
try:

View File

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

View File

@ -8,25 +8,28 @@ import re
import common.models
INVENTREE_SW_VERSION = "0.2.4 pre"
INVENTREE_SW_VERSION = "0.2.5 pre"
INVENTREE_API_VERSION = 6
"""
Increment thi API version number whenever there is a significant change to the API that any clients need to know about
v3 -> 2021-05-22:
- The updated StockItem "history tracking" now uses a different interface
v6 -> 2021-06-23
- Part and Company images can now be directly uploaded via the REST API
v5 -> 2021-06-21
- Adds API interface for manufacturer part parameters
v4 -> 2021-06-01
- BOM items can now accept "variant stock" to be assigned against them
- Many slight API tweaks were needed to get this to work properly!
v5 -> 2021-06-21
- Adds API interface for manufacturer part parameters
v3 -> 2021-05-22:
- The updated StockItem "history tracking" now uses a different interface
"""
INVENTREE_API_VERSION = 5
def inventreeInstanceName():
""" Returns the InstanceName settings for the current database """

View File

@ -22,17 +22,19 @@ class FileManager:
# Fields which are used for item matching (only one of them is needed)
ITEM_MATCH_HEADERS = []
# Fields which would be helpful but are not required
OPTIONAL_HEADERS = []
OPTIONAL_MATCH_HEADERS = []
EDITABLE_HEADERS = []
HEADERS = []
def __init__(self, file, name=None):
""" Initialize the FileManager class with a user-uploaded file object """
# Set name
if name:
self.name = name
@ -71,47 +73,34 @@ class FileManager:
raise ValidationError(_('Error reading file (incorrect dimension)'))
except KeyError:
raise ValidationError(_('Error reading file (data could be corrupted)'))
return cleaned_data
def process(self, file):
""" Process file """
self.data = self.__class__.validate(file)
def update_headers(self):
""" Update headers """
self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_HEADERS
self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_MATCH_HEADERS + self.OPTIONAL_HEADERS
def setup(self):
""" Setup headers depending on the file name """
"""
Setup headers
should be overriden in usage to set the Different Headers
"""
if not self.name:
return
if self.name == 'order':
self.REQUIRED_HEADERS = [
'Quantity',
]
self.ITEM_MATCH_HEADERS = [
'Manufacturer_MPN',
'Supplier_SKU',
]
self.OPTIONAL_HEADERS = [
'Purchase_Price',
'Reference',
'Notes',
]
# Update headers
self.update_headers()
# Update headers
self.update_headers()
def guess_header(self, header, threshold=80):
""" Try to match a header (from the file) to a list of known headers
Args:
header - Header name to look for
threshold - Match threshold for fuzzy search
@ -145,7 +134,7 @@ class FileManager:
return matches[0]['header']
return None
def columns(self):
""" Return a list of headers for the thingy """
headers = []

View File

@ -8,12 +8,7 @@ from __future__ import unicode_literals
from django import forms
from django.utils.translation import gettext as _
from djmoney.forms.fields import MoneyField
from InvenTree.forms import HelperForm
from InvenTree.helpers import clean_decimal
from common.settings import currency_code_default
from .files import FileManager
from .models import InvenTreeSetting
@ -32,7 +27,7 @@ class SettingEditForm(HelperForm):
]
class UploadFile(forms.Form):
class UploadFileForm(forms.Form):
""" Step 1 of FileManagementFormView """
file = forms.FileField(
@ -70,9 +65,9 @@ class UploadFile(forms.Form):
return file
class MatchField(forms.Form):
class MatchFieldForm(forms.Form):
""" Step 2 of FileManagementFormView """
def __init__(self, *args, **kwargs):
# Get FileManager
@ -88,7 +83,7 @@ class MatchField(forms.Form):
columns = file_manager.columns()
# Get headers choices
headers_choices = [(header, header) for header in file_manager.HEADERS]
# Create column fields
for col in columns:
field_name = col['name']
@ -103,9 +98,9 @@ class MatchField(forms.Form):
self.fields[field_name].initial = col['guess']
class MatchItem(forms.Form):
class MatchItemForm(forms.Form):
""" Step 3 of FileManagementFormView """
def __init__(self, *args, **kwargs):
# Get FileManager
@ -131,24 +126,41 @@ class MatchItem(forms.Form):
for col in row['data']:
# Get column matching
col_guess = col['column'].get('guess', None)
# Set field name
field_name = col_guess.lower() + '-' + str(row['index'])
# check if field def was overriden
overriden_field = self.get_special_field(col_guess, row, file_manager)
if overriden_field:
self.fields[field_name] = overriden_field
# Create input for required headers
if col_guess in file_manager.REQUIRED_HEADERS:
# Set field name
field_name = col_guess.lower() + '-' + str(row['index'])
elif col_guess in file_manager.REQUIRED_HEADERS:
# Get value
value = row.get(col_guess.lower(), '')
# Set field input box
if 'quantity' in col_guess.lower():
self.fields[field_name] = forms.CharField(
required=False,
widget=forms.NumberInput(attrs={
'name': 'quantity' + str(row['index']),
'class': 'numberinput', # form-control',
'type': 'number',
'min': '0',
'step': 'any',
'value': clean_decimal(row.get('quantity', '')),
})
)
self.fields[field_name] = forms.CharField(
required=True,
initial=value,
)
# Create item selection box
elif col_guess in file_manager.OPTIONAL_MATCH_HEADERS:
# Get item options
item_options = [(option.id, option) for option in row['match_options_' + col_guess]]
# Get item match
item_match = row['match_' + col_guess]
# Set field select box
self.fields[field_name] = forms.ChoiceField(
choices=[('', '-' * 10)] + item_options,
required=False,
widget=forms.Select(attrs={
'class': 'select bomselect',
})
)
# Update select box when match was found
if item_match:
self.fields[field_name].initial = item_match.id
# Create item selection box
elif col_guess in file_manager.ITEM_MATCH_HEADERS:
@ -176,22 +188,15 @@ class MatchItem(forms.Form):
# Optional entries
elif col_guess in file_manager.OPTIONAL_HEADERS:
# Set field name
field_name = col_guess.lower() + '-' + str(row['index'])
# Get value
value = row.get(col_guess.lower(), '')
# Set field input box
if 'price' in col_guess.lower():
self.fields[field_name] = MoneyField(
label=_(col_guess),
default_currency=currency_code_default(),
decimal_places=5,
max_digits=19,
required=False,
default_amount=clean_decimal(value),
)
else:
self.fields[field_name] = forms.CharField(
required=False,
initial=value,
)
self.fields[field_name] = forms.CharField(
required=False,
initial=value,
)
def get_special_field(self, col_guess, row, file_manager):
""" Function to be overriden in inherited forms to add specific form settings """
return None

View File

@ -205,6 +205,13 @@ class InvenTreeSetting(models.Model):
'validator': bool,
},
'PART_SHOW_IMPORT': {
'name': _('Show Import in Views'),
'description': _('Display the import wizard in some part views'),
'default': False,
'validator': bool,
},
'PART_SHOW_PRICE_IN_FORMS': {
'name': _('Show Price in Forms'),
'description': _('Display part price in some forms'),

View File

@ -13,8 +13,9 @@ from django.conf import settings
from django.core.files.storage import FileSystemStorage
from formtools.wizard.views import SessionWizardView
from crispy_forms.helper import FormHelper
from InvenTree.views import AjaxUpdateView
from InvenTree.views import AjaxUpdateView, AjaxView
from InvenTree.helpers import str2bool
from . import models
@ -117,7 +118,6 @@ class MultiStepFormView(SessionWizardView):
form_steps_description: description for each form
"""
form_list = []
form_steps_template = []
form_steps_description = []
file_manager = None
@ -126,10 +126,10 @@ class MultiStepFormView(SessionWizardView):
def __init__(self, *args, **kwargs):
""" Override init method to set media folder """
super().__init__(*args, **kwargs)
super().__init__(**kwargs)
self.process_media_folder()
def process_media_folder(self):
""" Process media folder """
@ -141,7 +141,7 @@ class MultiStepFormView(SessionWizardView):
def get_template_names(self):
""" Select template """
try:
# Get template
template = self.form_steps_template[self.steps.index]
@ -152,7 +152,7 @@ class MultiStepFormView(SessionWizardView):
def get_context_data(self, **kwargs):
""" Update context data """
# Retrieve current context
context = super().get_context_data(**kwargs)
@ -176,9 +176,9 @@ class FileManagementFormView(MultiStepFormView):
name = None
form_list = [
('upload', forms.UploadFile),
('fields', forms.MatchField),
('items', forms.MatchItem),
('upload', forms.UploadFileForm),
('fields', forms.MatchFieldForm),
('items', forms.MatchItemForm),
]
form_steps_description = [
_("Upload File"),
@ -188,11 +188,26 @@ class FileManagementFormView(MultiStepFormView):
media_folder = 'file_upload/'
extra_context_data = {}
def get_context_data(self, form, **kwargs):
def __init__(self, *args, **kwargs):
""" Initialize the FormView """
# Perform all checks and inits for MultiStepFormView
super().__init__(self, *args, **kwargs)
# Check for file manager class
if not hasattr(self, 'file_manager_class') and not issubclass(self.file_manager_class, FileManager):
raise NotImplementedError('A subclass of a file manager class needs to be set!')
def get_context_data(self, form=None, **kwargs):
""" Handle context data """
if form is None:
form = self.get_form()
context = super().get_context_data(form=form, **kwargs)
if self.steps.current in ('fields', 'items'):
# Get columns and row data
self.columns = self.file_manager.columns()
self.rows = self.file_manager.rows()
@ -203,7 +218,7 @@ class FileManagementFormView(MultiStepFormView):
elif self.steps.current == 'items':
# Set form table data
self.set_form_table_data(form=form)
# Update context
context.update({'rows': self.rows})
context.update({'columns': self.columns})
@ -227,7 +242,7 @@ class FileManagementFormView(MultiStepFormView):
# Get file
file = upload_files.get('upload-file', None)
if file:
self.file_manager = FileManager(file=file, name=self.name)
self.file_manager = self.file_manager_class(file=file, name=self.name)
def get_form_kwargs(self, step=None):
""" Update kwargs to dynamically build forms """
@ -262,13 +277,22 @@ class FileManagementFormView(MultiStepFormView):
self.get_form_table_data(data)
self.set_form_table_data()
self.get_field_selection()
kwargs['row_data'] = self.rows
return kwargs
return super().get_form_kwargs()
def get_form(self, step=None, data=None, files=None):
""" add crispy-form helper to form """
form = super().get_form(step=step, data=data, files=files)
form.helper = FormHelper()
form.helper.form_show_labels = False
return form
def get_form_table_data(self, form_data):
""" Extract table cell data from form data and fields.
These data are used to maintain state between sessions.
@ -327,7 +351,7 @@ class FileManagementFormView(MultiStepFormView):
col_id = int(s[3])
except ValueError:
continue
if row_id not in self.row_data:
self.row_data[row_id] = {}
@ -362,19 +386,20 @@ class FileManagementFormView(MultiStepFormView):
'name': self.column_names[idx],
'guess': self.column_selections[idx],
}
cell_data = {
'cell': item,
'idx': idx,
'column': column_data,
}
data.append(cell_data)
row = {
'index': row_idx,
'data': data,
'errors': {},
}
self.rows.append(row)
# In the item selection step: update row data with mapping to form fields
@ -414,6 +439,33 @@ class FileManagementFormView(MultiStepFormView):
"""
pass
def get_clean_items(self):
""" returns dict with all cleaned values """
items = {}
for form_key, form_value in self.get_all_cleaned_data().items():
# Split key from row value
try:
(field, idx) = form_key.split('-')
except ValueError:
continue
try:
if idx not in items:
# Insert into items
items.update({
idx: {
self.form_field_map[field]: form_value,
}
})
else:
# Update items
items[idx][self.form_field_map[field]] = form_value
except KeyError:
pass
return items
def check_field_selection(self, form):
""" Check field matching """
@ -431,7 +483,7 @@ class FileManagementFormView(MultiStepFormView):
if col in self.column_selections.values():
part_match_found = True
break
# If not, notify user
if not part_match_found:
for col in self.file_manager.ITEM_MATCH_HEADERS:
@ -451,7 +503,7 @@ class FileManagementFormView(MultiStepFormView):
n = list(self.column_selections.values()).count(self.column_selections[col])
if n > 1 and self.column_selections[col] not in duplicates:
duplicates.append(self.column_selections[col])
# Store extra context data
self.extra_context_data = {
'missing_columns': missing_columns,
@ -497,3 +549,70 @@ class FileManagementFormView(MultiStepFormView):
return self.render(form)
return super().post(*args, **kwargs)
class FileManagementAjaxView(AjaxView):
""" Use a FileManagementFormView as base for a AjaxView
Inherit this class before inheriting the base FileManagementFormView
ajax_form_steps_template: templates for rendering ajax
validate: function to validate the current form -> normally point to the same function in the base FileManagementFormView
"""
def post(self, request):
# check if back-step button was selected
wizard_back = self.request.POST.get('act-btn_back', None)
if wizard_back:
back_step_index = self.get_step_index() - 1
self.storage.current_step = list(self.get_form_list().keys())[back_step_index]
return self.renderJsonResponse(request, data={'form_valid': None})
# validate form
form = self.get_form(data=self.request.POST, files=self.request.FILES)
form_valid = self.validate(self.steps.current, form)
# check if valid
if not form_valid:
return self.renderJsonResponse(request, data={'form_valid': None})
# store the cleaned data and files.
self.storage.set_step_data(self.steps.current, self.process_step(form))
self.storage.set_step_files(self.steps.current, self.process_step_files(form))
# check if the current step is the last step
if self.steps.current == self.steps.last:
# call done - to process data, returned response is not used
self.render_done(form)
data = {'form_valid': True, 'success': _('Parts imported')}
return self.renderJsonResponse(request, data=data)
else:
self.storage.current_step = self.steps.next
return self.renderJsonResponse(request, data={'form_valid': None})
def get(self, request):
if 'reset' in request.GET:
# reset form
self.storage.reset()
self.storage.current_step = self.steps.first
return self.renderJsonResponse(request)
def renderJsonResponse(self, request, form=None, data={}, context=None):
""" always set the right templates before rendering """
self.setTemplate()
return super().renderJsonResponse(request, form=form, data=data, context=context)
def get_data(self):
data = super().get_data()
data['hideErrorMessage'] = '1' # hide the error
buttons = [{'name': 'back', 'title': _('Previous Step')}] if self.get_step_index() > 0 else []
data['buttons'] = buttons # set buttons
return data
def setTemplate(self):
""" set template name and title """
self.ajax_template_name = self.ajax_form_steps_template[self.get_step_index()]
self.ajax_form_title = self.form_steps_description[self.get_step_index()]
def validate(self, obj, form, **kwargs):
raise NotImplementedError('This function needs to be overridden!')

View File

@ -44,8 +44,6 @@ class CompanyConfig(AppConfig):
company.image.render_variations(replace=False)
except FileNotFoundError:
logger.warning(f"Image file '{company.image}' missing")
company.image = None
company.save()
except UnidentifiedImageError:
logger.warning(f"Image file '{company.image}' is invalid")
except (OperationalError, ProgrammingError):

View File

@ -6,14 +6,15 @@ from rest_framework import serializers
from sql_util.utils import SubqueryCount
from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeImageSerializerField
from part.serializers import PartBriefSerializer
from .models import Company
from .models import ManufacturerPart, ManufacturerPartParameter
from .models import SupplierPart, SupplierPriceBreak
from InvenTree.serializers import InvenTreeModelSerializer
from part.serializers import PartBriefSerializer
class CompanyBriefSerializer(InvenTreeModelSerializer):
""" Serializer for Company object (limited detail) """
@ -52,7 +53,7 @@ class CompanySerializer(InvenTreeModelSerializer):
url = serializers.CharField(source='get_absolute_url', read_only=True)
image = serializers.CharField(source='get_thumbnail_url', read_only=True)
image = InvenTreeImageSerializerField(required=False, allow_null=True)
parts_supplied = serializers.IntegerField(read_only=True)
parts_manufactured = serializers.IntegerField(read_only=True)

View File

@ -139,11 +139,17 @@
enableDragAndDrop(
"#company-thumb",
"{% url 'company-image' company.id %}",
"{% url 'api-company-detail' company.id %}",
{
label: 'image',
method: 'PATCH',
success: function(data, status, xhr) {
location.reload();
if (data.image) {
$('#company-image').attr('src', data.image);
} else {
location.reload();
}
}
}
);

View File

@ -50,10 +50,15 @@ class CompanyTest(InvenTreeAPITestCase):
self.assertEqual(response.data['name'], 'ACME')
# Change the name of the company
# Note we should not have the correct permissions (yet)
data = response.data
data['name'] = 'ACMOO'
response = self.client.patch(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
response = self.client.patch(url, data, format='json', expected_code=400)
self.assignRole('company.change')
response = self.client.patch(url, data, format='json', expected_code=200)
self.assertEqual(response.data['name'], 'ACMOO')
def test_company_search(self):

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 djmoney.forms.fields import MoneyField
from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField
from InvenTree.fields import DatePickerFormField
from InvenTree.helpers import clean_decimal
from common.models import InvenTreeSetting
from common.forms import MatchItemForm
import part.models
from stock.models import StockLocation
@ -291,3 +298,37 @@ class EditSalesOrderAllocationForm(HelperForm):
'line',
'item',
'quantity']
class OrderMatchItemForm(MatchItemForm):
""" Override MatchItemForm fields """
def get_special_field(self, col_guess, row, file_manager):
""" Set special fields """
# set quantity field
if 'quantity' in col_guess.lower():
return forms.CharField(
required=False,
widget=forms.NumberInput(attrs={
'name': 'quantity' + str(row['index']),
'class': 'numberinput',
'type': 'number',
'min': '0',
'step': 'any',
'value': clean_decimal(row.get('quantity', '')),
})
)
# set price field
elif 'price' in col_guess.lower():
return MoneyField(
label=_(col_guess),
default_currency=InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY'),
decimal_places=5,
max_digits=19,
required=False,
default_amount=clean_decimal(row.get('purchase_price', '')),
)
# return default
return super().get_special_field(col_guess, row, file_manager)

View File

@ -17,8 +17,7 @@ from InvenTree.serializers import InvenTreeAttachmentSerializerField
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
from part.serializers import PartBriefSerializer
from stock.serializers import LocationBriefSerializer
from stock.serializers import StockItemSerializer, LocationSerializer
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import PurchaseOrderAttachment, SalesOrderAttachment

View File

@ -2,6 +2,7 @@
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% load crispy_forms_tags %}
{% block form_alert %}
{% if form.errors %}
@ -67,7 +68,7 @@
<td>
{% for field in form.visible_fields %}
{% if field.name == row.quantity %}
{{ field }}
{{ field|as_crispy_field }}
{% endif %}
{% endfor %}
{% if row.errors.quantity %}
@ -80,19 +81,19 @@
{% if item.column.guess == 'Purchase_Price' %}
{% for field in form.visible_fields %}
{% if field.name == row.purchase_price %}
{{ field }}
{{ field|as_crispy_field }}
{% endif %}
{% endfor %}
{% elif item.column.guess == 'Reference' %}
{% for field in form.visible_fields %}
{% if field.name == row.reference %}
{{ field }}
{{ field|as_crispy_field }}
{% endif %}
{% endfor %}
{% elif item.column.guess == 'Notes' %}
{% for field in form.visible_fields %}
{% if field.name == row.notes %}
{{ field }}
{{ field|as_crispy_field }}
{% endif %}
{% endfor %}
{% else %}

View File

@ -30,7 +30,9 @@ from stock.models import StockItem, StockLocation
from part.models import Part
from common.models import InvenTreeSetting
from common.forms import UploadFileForm, MatchFieldForm
from common.views import FileManagementFormView
from common.files import FileManager
from . import forms as order_forms
from part.views import PartPricing
@ -572,7 +574,28 @@ class SalesOrderShip(AjaxUpdateView):
class PurchaseOrderUpload(FileManagementFormView):
''' PurchaseOrder: Upload file, match to fields and parts (using multi-Step form) '''
class OrderFileManager(FileManager):
REQUIRED_HEADERS = [
'Quantity',
]
ITEM_MATCH_HEADERS = [
'Manufacturer_MPN',
'Supplier_SKU',
]
OPTIONAL_HEADERS = [
'Purchase_Price',
'Reference',
'Notes',
]
name = 'order'
form_list = [
('upload', UploadFileForm),
('fields', MatchFieldForm),
('items', order_forms.OrderMatchItemForm),
]
form_steps_template = [
'order/order_wizard/po_upload.html',
'order/order_wizard/match_fields.html',
@ -583,7 +606,6 @@ class PurchaseOrderUpload(FileManagementFormView):
_("Match Fields"),
_("Match Supplier Parts"),
]
# Form field name: PurchaseOrderLineItem field
form_field_map = {
'item_select': 'part',
'quantity': 'quantity',
@ -591,6 +613,7 @@ class PurchaseOrderUpload(FileManagementFormView):
'reference': 'reference',
'notes': 'notes',
}
file_manager_class = OrderFileManager
def get_order(self):
""" Get order or return 404 """
@ -598,6 +621,8 @@ class PurchaseOrderUpload(FileManagementFormView):
return get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
def get_context_data(self, form, **kwargs):
""" Handle context data for order """
context = super().get_context_data(form=form, **kwargs)
order = self.get_order()
@ -708,26 +733,7 @@ class PurchaseOrderUpload(FileManagementFormView):
""" Once all the data is in, process it to add PurchaseOrderLineItem instances to the order """
order = self.get_order()
items = {}
for form_key, form_value in self.get_all_cleaned_data().items():
# Split key from row value
try:
(field, idx) = form_key.split('-')
except ValueError:
continue
if idx not in items:
# Insert into items
items.update({
idx: {
self.form_field_map[field]: form_value,
}
})
else:
# Update items
items[idx][self.form_field_map[field]] = form_value
items = self.get_clean_items()
# Create PurchaseOrderLineItem instances
for purchase_order_item in items.values():

View File

@ -39,7 +39,8 @@ class PartConfig(AppConfig):
logger.debug("InvenTree: Checking Part image thumbnails")
try:
for part in Part.objects.all():
# Only check parts which have images
for part in Part.objects.exclude(image=None):
if part.image:
url = part.image.thumbnail.name
loc = os.path.join(settings.MEDIA_ROOT, url)
@ -50,8 +51,7 @@ class PartConfig(AppConfig):
part.image.render_variations(replace=False)
except FileNotFoundError:
logger.warning(f"Image file '{part.image}' missing")
part.image = None
part.save()
pass
except UnidentifiedImageError:
logger.warning(f"Image file '{part.image}' is invalid")
except (OperationalError, ProgrammingError):

View File

@ -1479,16 +1479,17 @@ class Part(MPTTModel):
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
Args:
quantity: Number of units to calculate price for
buy: Include supplier 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:
return None
@ -1576,9 +1577,10 @@ class Part(MPTTModel):
- Supplier price (if purchased from suppliers)
- BOM price (if built from other parts)
- Internal price (if set for the part)
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
@ -2499,7 +2501,9 @@ class BomItem(models.Model):
def price_range(self):
""" 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:
return prange

View File

@ -7,12 +7,15 @@ from decimal import Decimal
from django.db import models
from django.db.models import Q
from django.db.models.functions import Coalesce
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
InvenTreeModelSerializer)
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum
from djmoney.contrib.django_rest_framework import MoneyField
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer)
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from stock.models import StockItem
from .models import (BomItem, Part, PartAttachment, PartCategory,
@ -300,7 +303,7 @@ class PartSerializer(InvenTreeModelSerializer):
stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True)
image = serializers.CharField(source='get_image_url', read_only=True)
image = InvenTreeImageSerializerField(required=False, allow_null=True)
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
starred = serializers.SerializerMethodField()

View File

@ -8,6 +8,15 @@
{% block content %}
{% if messages %}
{% for message in messages %}
<div class='{{ message.tags }}'>
{{ message|safe }}
</div>
{% endfor %}
{% endif %}
<div class='panel panel-default panel-inventree'>
<div class='row'>
<div class='col-sm-6'>

View File

@ -1,4 +1,7 @@
{% load i18n %}
{% load inventree_extras %}
{% settings_value 'PART_SHOW_IMPORT' as show_import %}
<ul class='list-group'>
@ -30,6 +33,15 @@
</a>
</li>
{% if show_import and user.is_staff and roles.part.add %}
<li class='list-group-item {% if tab == "import" %}active{% endif %}' title='{% trans "Import Parts" %}'>
<a href='{% url "part-import" %}'>
<span class='fas fa-file-upload sidebar-icon'></span>
{% trans "Import Parts" %}
</a>
</li>
{% endif %}
{% if category %}
<li class='list-group-item {% if tab == "parameters" %}active{% endif %}' title='{% trans "Parameters" %}'>
<a href='{% url "category-parametric" category.id %}'>

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

@ -239,11 +239,19 @@
enableDragAndDrop(
'#part-thumb',
"{% url 'part-image-upload' part.id %}",
"{% url 'api-part-detail' part.id %}",
{
label: 'image',
method: 'PATCH',
success: function(data, status, xhr) {
location.reload();
// If image / thumbnail data present, live update
if (data.image) {
$('#part-image').attr('src', data.image);
} else {
// Otherwise, reload the page
location.reload();
}
}
}
);

View File

@ -1,15 +1,19 @@
# -*- coding: utf-8 -*-
from rest_framework import status
import PIL
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import StockStatus
from part.models import Part, PartCategory
from stock.models import StockItem
from company.models import Company
from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import StockStatus
from common.models import InvenTreeSetting
class PartAPITest(InvenTreeAPITestCase):
@ -308,6 +312,59 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(len(data['results']), n)
def test_default_values(self):
"""
Tests for 'default' values:
Ensure that unspecified fields revert to "default" values
(as specified in the model field definition)
"""
url = reverse('api-part-list')
response = self.client.post(url, {
'name': 'all defaults',
'description': 'my test part',
'category': 1,
})
data = response.data
# Check that the un-specified fields have used correct default values
self.assertTrue(data['active'])
self.assertFalse(data['virtual'])
# By default, parts are not purchaseable
self.assertFalse(data['purchaseable'])
# Set the default 'purchaseable' status to True
InvenTreeSetting.set_setting(
'PART_PURCHASEABLE',
True,
self.user
)
response = self.client.post(url, {
'name': 'all defaults',
'description': 'my test part 2',
'category': 1,
})
# Part should now be purchaseable by default
self.assertTrue(response.data['purchaseable'])
# "default" values should not be used if the value is specified
response = self.client.post(url, {
'name': 'all defaults',
'description': 'my test part 2',
'category': 1,
'active': False,
'purchaseable': False,
})
self.assertFalse(response.data['active'])
self.assertFalse(response.data['purchaseable'])
class PartDetailTests(InvenTreeAPITestCase):
"""
@ -388,7 +445,14 @@ class PartDetailTests(InvenTreeAPITestCase):
# Try to remove the part
response = self.client.delete(url)
# As the part is 'active' we cannot delete it
self.assertEqual(response.status_code, 405)
# So, let's make it not active
response = self.patch(url, {'active': False}, expected_code=200)
response = self.client.delete(url)
self.assertEqual(response.status_code, 204)
# Part count should have reduced
@ -473,6 +537,72 @@ class PartDetailTests(InvenTreeAPITestCase):
self.assertEqual(response.status_code, 200)
def test_image_upload(self):
"""
Test that we can upload an image to the part API
"""
self.assignRole('part.add')
# Create a new part
response = self.client.post(
reverse('api-part-list'),
{
'name': 'imagine',
'description': 'All the people',
'category': 1,
},
expected_code=201
)
pk = response.data['pk']
url = reverse('api-part-detail', kwargs={'pk': pk})
p = Part.objects.get(pk=pk)
# Part should not have an image!
with self.assertRaises(ValueError):
print(p.image.file)
# Create a custom APIClient for file uploads
# Ref: https://stackoverflow.com/questions/40453947/how-to-generate-a-file-upload-test-request-with-django-rest-frameworks-apireq
upload_client = APIClient()
upload_client.force_authenticate(user=self.user)
# Try to upload a non-image file
with open('dummy_image.txt', 'w') as dummy_image:
dummy_image.write('hello world')
with open('dummy_image.txt', 'rb') as dummy_image:
response = upload_client.patch(
url,
{
'image': dummy_image,
},
format='multipart',
)
self.assertEqual(response.status_code, 400)
# Now try to upload a valid image file
img = PIL.Image.new('RGB', (128, 128), color='red')
img.save('dummy_image.jpg')
with open('dummy_image.jpg', 'rb') as dummy_image:
response = upload_client.patch(
url,
{
'image': dummy_image,
},
format='multipart',
)
self.assertEqual(response.status_code, 200)
# And now check that the image has been set
p = Part.objects.get(pk=pk)
class PartAPIAggregationTest(InvenTreeAPITestCase):
"""

View File

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

View File

@ -126,6 +126,10 @@ part_urls = [
# Create a new part
url(r'^new/?', views.PartCreate.as_view(), name='part-create'),
# Upload a part
url(r'^import/', views.PartImport.as_view(), name='part-import'),
url(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'),
# Create a new BOM item
url(r'^bom/new/?', views.BomItemCreate.as_view(), name='bom-item-create'),

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 import HiddenInput, CheckboxInput
from django.conf import settings
from django.contrib import messages
from moneyed import CURRENCIES
from djmoney.contrib.exchange.models import convert_money
@ -40,6 +41,10 @@ from .models import PartSellPriceBreak, PartInternalPriceBreak
from common.models import InvenTreeSetting
from company.models import SupplierPart
from common.files import FileManager
from common.views import FileManagementFormView, FileManagementAjaxView
from stock.models import StockLocation
import common.settings as inventree_settings
@ -719,6 +724,168 @@ class PartCreate(AjaxCreateView):
return initials
class PartImport(FileManagementFormView):
''' Part: Upload file, match to fields and import parts(using multi-Step form) '''
permission_required = 'part.add'
class PartFileManager(FileManager):
REQUIRED_HEADERS = [
'Name',
'Description',
]
OPTIONAL_MATCH_HEADERS = [
'Category',
'default_location',
'default_supplier',
]
OPTIONAL_HEADERS = [
'Keywords',
'IPN',
'Revision',
'Link',
'default_expiry',
'minimum_stock',
'Units',
'Notes',
]
name = 'part'
form_steps_template = [
'part/import_wizard/part_upload.html',
'part/import_wizard/match_fields.html',
'part/import_wizard/match_references.html',
]
form_steps_description = [
_("Upload File"),
_("Match Fields"),
_("Match References"),
]
form_field_map = {
'name': 'name',
'description': 'description',
'keywords': 'keywords',
'ipn': 'ipn',
'revision': 'revision',
'link': 'link',
'default_expiry': 'default_expiry',
'minimum_stock': 'minimum_stock',
'units': 'units',
'notes': 'notes',
'category': 'category',
'default_location': 'default_location',
'default_supplier': 'default_supplier',
}
file_manager_class = PartFileManager
def get_field_selection(self):
""" Fill the form fields for step 3 """
# fetch available elements
self.allowed_items = {}
self.matches = {}
self.allowed_items['Category'] = PartCategory.objects.all()
self.matches['Category'] = ['name__contains']
self.allowed_items['default_location'] = StockLocation.objects.all()
self.matches['default_location'] = ['name__contains']
self.allowed_items['default_supplier'] = SupplierPart.objects.all()
self.matches['default_supplier'] = ['SKU__contains']
# setup
self.file_manager.setup()
# collect submitted column indexes
col_ids = {}
for col in self.file_manager.HEADERS:
index = self.get_column_index(col)
if index >= 0:
col_ids[col] = index
# parse all rows
for row in self.rows:
# check each submitted column
for idx in col_ids:
data = row['data'][col_ids[idx]]['cell']
if idx in self.file_manager.OPTIONAL_MATCH_HEADERS:
try:
exact_match = self.allowed_items[idx].get(**{a: data for a in self.matches[idx]})
except (ValueError, self.allowed_items[idx].model.DoesNotExist, self.allowed_items[idx].model.MultipleObjectsReturned):
exact_match = None
row['match_options_' + idx] = self.allowed_items[idx]
row['match_' + idx] = exact_match
continue
# general fields
row[idx.lower()] = data
def done(self, form_list, **kwargs):
""" Create items """
items = self.get_clean_items()
import_done = 0
import_error = []
# Create Part instances
for part_data in items.values():
# set related parts
optional_matches = {}
for idx in self.file_manager.OPTIONAL_MATCH_HEADERS:
if idx.lower() in part_data:
try:
optional_matches[idx] = self.allowed_items[idx].get(pk=int(part_data[idx.lower()]))
except (ValueError, self.allowed_items[idx].model.DoesNotExist, self.allowed_items[idx].model.MultipleObjectsReturned):
optional_matches[idx] = None
else:
optional_matches[idx] = None
# add part
new_part = Part(
name=part_data.get('name', ''),
description=part_data.get('description', ''),
keywords=part_data.get('keywords', None),
IPN=part_data.get('ipn', None),
revision=part_data.get('revision', None),
link=part_data.get('link', None),
default_expiry=part_data.get('default_expiry', 0),
minimum_stock=part_data.get('minimum_stock', 0),
units=part_data.get('units', None),
notes=part_data.get('notes', None),
category=optional_matches['Category'],
default_location=optional_matches['default_location'],
default_supplier=optional_matches['default_supplier'],
)
try:
new_part.save()
import_done += 1
except ValidationError as _e:
import_error.append(', '.join(set(_e.messages)))
# Set alerts
if import_done:
alert = f"<strong>{_('Part-Import')}</strong><br>{_('Imported {n} parts').format(n=import_done)}"
messages.success(self.request, alert)
if import_error:
error_text = '\n'.join([f'<li><strong>x{import_error.count(a)}</strong>: {a}</li>' for a in set(import_error)])
messages.error(self.request, f"<strong>{_('Some errors occured:')}</strong><br><ul>{error_text}</ul>")
return HttpResponseRedirect(reverse('part-index'))
class PartImportAjax(FileManagementAjaxView, PartImport):
ajax_form_steps_template = [
'part/import_wizard/ajax_part_upload.html',
'part/import_wizard/ajax_match_fields.html',
'part/import_wizard/ajax_match_references.html',
]
def validate(self, obj, form, **kwargs):
return PartImport.validate(self, self.steps.current, form, **kwargs)
class PartNotes(UpdateView):
""" View for editing the 'notes' field of a Part object.
Presents a live markdown editor.
@ -847,11 +1014,13 @@ class PartPricingView(PartDetail):
# BOM Information for Pie-Chart
if part.has_bom:
# get internal price setting
use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
ctx_bom_parts = []
# iterate over all bom-items
for item in part.bom_items.all():
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
if price: # check if price available

View File

@ -354,16 +354,18 @@ class StockItemTest(StockAPITestCase):
self.assertContains(response, 'does not exist', status_code=status.HTTP_400_BAD_REQUEST)
# POST without quantity
response = self.client.post(
response = self.post(
self.list_url,
data={
{
'part': 1,
'location': 1,
}
},
expected_code=201,
)
self.assertContains(response, 'This field is required', status_code=status.HTTP_400_BAD_REQUEST)
# Item should have been created with default quantity
self.assertEqual(response.data['quantity'], 1)
# POST with quantity and part and location
response = self.client.post(
self.list_url,

View File

@ -41,6 +41,22 @@
</tbody>
</table>
<h4>{% trans "Part Import" %}</h4>
<button class='btn btn-success' id='import-part'>
<span class='fas fa-plus-circle'></span> {% trans "Import Part" %}
</button>
<table class='table table-striped table-condensed'>
{% include "InvenTree/settings/header.html" %}
<tbody>
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_IMPORT" icon="fa-file-upload" %}
</tbody>
</table>
<h4>{% trans "Part Parameter Templates" %}</h4>
<div id='param-buttons'>
@ -125,4 +141,8 @@
});
});
$("#import-part").click(function() {
launchModalForm("{% url 'api-part-import' %}?reset", {});
});
{% endblock %}

View File

@ -179,27 +179,32 @@ function loadStockTestResultsTable(table, options) {
var match = false;
var override = false;
// Extract the simplified test key
var key = item.key;
// 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) {
item.test_name = row.test_name;
item.required = row.required;
match = true;
if (row.result == null) {
item.parent = parent_node;
tableData[index] = item;
tableData[idx] = item;
override = true;
} else {
item.parent = row.pk;
}
match = true;
break;
}
});
}
// No match could be found
if (!match) {

View File

@ -13,7 +13,7 @@ class UsersConfig(AppConfig):
def ready(self):
if canAppAccessDatabase():
if canAppAccessDatabase(allow_test=True):
try:
self.assign_permissions()

View File

@ -276,7 +276,7 @@ def update_group_roles(group, debug=False):
"""
if not canAppAccessDatabase():
if not canAppAccessDatabase(allow_test=True):
return
# List of permissions already associated with this group