Merge branch 'master' of https://github.com/inventree/InvenTree into allocated-sort-qty

This commit is contained in:
Matthias 2021-06-29 00:17:29 +02:00
commit 153be2df13
76 changed files with 11485 additions and 8275 deletions

49
.github/workflows/python.yaml vendored Normal file
View File

@ -0,0 +1,49 @@
# Run python library tests whenever code is pushed to master
name: Python Bindings
on:
push:
branches:
- master
pull_request:
branches-ignore:
- l10*
jobs:
python:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_NAME: './test_db.sqlite'
INVENTREE_DB_ENGINE: 'sqlite3'
INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Install InvenTree
run: |
sudo apt-get update
sudo apt-get install python3-dev python3-pip python3-venv
pip3 install invoke
invoke install
invoke migrate
- name: Download Python Code
run: |
git clone --depth 1 https://github.com/inventree/inventree-python ./inventree-python
- name: Start Server
run: |
invoke import-records -f ./inventree-python/test/test_data.json
invoke server -a 127.0.0.1:8000 &
sleep 60
- name: Run Tests
run: |
cd inventree-python
invoke test

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)
@ -73,22 +78,50 @@ class InvenTreeAPITestCase(APITestCase):
ruleset.save()
break
def get(self, url, data={}, code=200):
def get(self, url, data={}, expected_code=200):
"""
Issue a GET request
"""
response = self.client.get(url, data, format='json')
self.assertEqual(response.status_code, code)
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response
def post(self, url, data):
def post(self, url, data, expected_code=None):
"""
Issue a POST request
"""
response = self.client.post(url, data=data, format='json')
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response
def delete(self, url, expected_code=None):
"""
Issue a DELETE request
"""
response = self.client.delete(url)
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response
def patch(self, url, data, files=None, expected_code=None):
"""
Issue a PATCH request
"""
response = self.client.patch(url, data=data, files=files, format='json')
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response

View File

@ -21,6 +21,9 @@ import InvenTree.version
from common.models import InvenTreeSetting
from .settings import MEDIA_URL, STATIC_URL
from common.settings import currency_code_default
from djmoney.money import Money
def getSetting(key, backup_value=None):
@ -247,6 +250,22 @@ def decimal2string(d):
return s.rstrip("0").rstrip(".")
def decimal2money(d, currency=None):
"""
Format a Decimal number as Money
Args:
d: A python Decimal object
currency: Currency of the input amount, defaults to default currency in settings
Returns:
A Money object from the input(s)
"""
if not currency:
currency = currency_code_default()
return Money(d, currency)
def WrapWithQuotes(text, quote='"'):
""" Wrap the supplied text with quotes

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,16 +2,19 @@
Serializers used in various InvenTree apps
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from rest_framework import serializers
import os
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
class UserSerializer(serializers.ModelSerializer):
@ -39,18 +42,103 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
but also ensures that the underlying model class data are checked on validation.
"""
def validate(self, data):
""" Perform serializer 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 and data is not empty:
# 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():
"""
Update the field IF (and ONLY IF):
- The field has a specified default value
- The field does not already have a value set
"""
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().copy()
# 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,
this class ensures that the underlying model is also validated.
"""
# Run any native validation checks first (may throw an ValidationError)
data = super(serializers.ModelSerializer, self).validate(data)
# Run any native validation checks first (may raise a ValidationError)
data = super().run_validation(data)
# Now ensure the underlying model is correct
instance = self.Meta.model(**data)
instance.clean()
if not hasattr(self, 'instance') or self.instance is None:
# No instance exists (we are creating a new one)
instance = self.Meta.model(**data)
else:
# Instance already exists (we are updating!)
instance = self.instance
# Update instance fields
for attr, value in data.items():
setattr(instance, attr, value)
# Run a 'full_clean' on the model.
# Note that by default, DRF does *not* perform full model validation!
try:
instance.full_clean()
except (ValidationError, DjangoValidationError) as exc:
raise ValidationError(detail=serializers.as_serializer_error(exc))
return data
@ -82,3 +170,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:
@ -80,7 +80,7 @@ def heartbeat():
try:
from django_q.models import Success
logger.warning("Could not perform heartbeat task - App registry not ready")
logger.info("Could not perform heartbeat task - App registry not ready")
except AppRegistryNotReady:
return
@ -105,7 +105,7 @@ def delete_successful_tasks():
try:
from django_q.models import Success
except AppRegistryNotReady:
logger.warning("Could not perform 'delete_successful_tasks' - App registry not ready")
logger.info("Could not perform 'delete_successful_tasks' - App registry not ready")
return
threshold = datetime.now() - timedelta(days=30)
@ -126,6 +126,7 @@ def check_for_updates():
import common.models
except AppRegistryNotReady:
# Apps not yet loaded!
logger.info("Could not perform 'check_for_updates' - App registry not ready")
return
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
@ -172,6 +173,7 @@ def update_exchange_rates():
from django.conf import settings
except AppRegistryNotReady:
# Apps not yet loaded!
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
return
except:
# Other error?

View File

@ -2,6 +2,11 @@
from rest_framework import status
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.urls import reverse
from InvenTree.api_tester import InvenTreeAPITestCase
@ -11,6 +16,87 @@ from users.models import RuleSet
from base64 import b64encode
class HTMLAPITests(TestCase):
"""
Test that we can access the REST API endpoints via the HTML interface.
History: Discovered on 2021-06-28 a bug in InvenTreeModelSerializer,
which raised an AssertionError when using the HTML API interface,
while the regular JSON interface continued to work as expected.
"""
def setUp(self):
super().setUp()
# Create a user
user = get_user_model()
self.user = user.objects.create_user(
username='username',
email='user@email.com',
password='password'
)
# Put the user into a group with the correct permissions
group = Group.objects.create(name='mygroup')
self.user.groups.add(group)
# Give the group *all* the permissions!
for rule in group.rule_sets.all():
rule.can_view = True
rule.can_change = True
rule.can_add = True
rule.can_delete = True
rule.save()
self.client.login(username='username', password='password')
def test_part_api(self):
url = reverse('api-part-list')
# Check JSON response
response = self.client.get(url, HTTP_ACCEPT='application/json')
self.assertEqual(response.status_code, 200)
# Check HTTP response
response = self.client.get(url, HTTP_ACCEPT='text/html')
self.assertEqual(response.status_code, 200)
def test_build_api(self):
url = reverse('api-build-list')
# Check JSON response
response = self.client.get(url, HTTP_ACCEPT='application/json')
self.assertEqual(response.status_code, 200)
# Check HTTP response
response = self.client.get(url, HTTP_ACCEPT='text/html')
self.assertEqual(response.status_code, 200)
def test_stock_api(self):
url = reverse('api-stock-list')
# Check JSON response
response = self.client.get(url, HTTP_ACCEPT='application/json')
self.assertEqual(response.status_code, 200)
# Check HTTP response
response = self.client.get(url, HTTP_ACCEPT='text/html')
self.assertEqual(response.status_code, 200)
def test_company_list(self):
url = reverse('api-company-list')
# Check JSON response
response = self.client.get(url, HTTP_ACCEPT='application/json')
self.assertEqual(response.status_code, 200)
# Check HTTP response
response = self.client.get(url, HTTP_ACCEPT='text/html')
self.assertEqual(response.status_code, 200)
class APITests(InvenTreeAPITestCase):
""" Tests for the InvenTree API """
@ -77,7 +163,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

@ -337,7 +337,7 @@ class AjaxMixin(InvenTreeRoleMixin):
# Do nothing by default
pass
def renderJsonResponse(self, request, form=None, data={}, context=None):
def renderJsonResponse(self, request, form=None, data=None, context=None):
""" Render a JSON response based on specific class context.
Args:
@ -349,6 +349,9 @@ class AjaxMixin(InvenTreeRoleMixin):
Returns:
JSON response object
"""
# a empty dict as default can be dangerous - set it here if empty
if not data:
data = {}
if not request.is_ajax():
return HttpResponseRedirect('/')

View File

@ -40,7 +40,8 @@ def assign_bom_items(apps, schema_editor):
except BomItem.DoesNotExist:
pass
print(f"Assigned BomItem for {count_valid}/{count_total} entries")
if count_total > 0:
print(f"Assigned BomItem for {count_valid}/{count_total} entries")
def unassign_bom_items(apps, schema_editor):

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,20 @@ 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'),
'default': True,
'validator': bool,
},
'PART_INTERNAL_PRICE': {
'name': _('Internal Prices'),
'description': _('Enable internal prices for parts'),

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

@ -71,7 +71,8 @@ def migrate_currencies(apps, schema_editor):
count += 1
print(f"Updated {count} SupplierPriceBreak rows")
if count > 0:
print(f"Updated {count} SupplierPriceBreak rows")
def reverse_currencies(apps, schema_editor):
"""

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):
@ -119,7 +124,9 @@ class ManufacturerTest(InvenTreeAPITestCase):
data = {
'MPN': 'MPN-TEST-123',
}
response = self.client.patch(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['MPN'], 'MPN-TEST-123')

View File

@ -623,7 +623,8 @@ class SupplierPartEdit(AjaxUpdateView):
supplier_part = self.get_object()
if supplier_part.manufacturer_part:
initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id
if supplier_part.manufacturer_part.manufacturer:
initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id
initials['MPN'] = supplier_part.manufacturer_part.MPN
return initials

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

@ -157,7 +157,7 @@ class POList(generics.ListCreateAPIView):
ordering = '-creation_date'
class PODetail(generics.RetrieveUpdateAPIView):
class PODetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of a PurchaseOrder object """
queryset = PurchaseOrder.objects.all()
@ -382,7 +382,7 @@ class SOList(generics.ListCreateAPIView):
ordering = '-creation_date'
class SODetail(generics.RetrieveUpdateAPIView):
class SODetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for detail view of a SalesOrder object.
"""

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
@ -93,8 +92,10 @@ class POSerializer(InvenTreeModelSerializer):
]
read_only_fields = [
'reference',
'status'
'issue_date',
'complete_date',
'creation_date',
]
@ -110,8 +111,9 @@ class POLineItemSerializer(InvenTreeModelSerializer):
self.fields.pop('part_detail')
self.fields.pop('supplier_part_detail')
quantity = serializers.FloatField()
received = serializers.FloatField()
# TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
quantity = serializers.FloatField(default=1)
received = serializers.FloatField(default=0)
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
@ -226,8 +228,9 @@ class SalesOrderSerializer(InvenTreeModelSerializer):
]
read_only_fields = [
'reference',
'status'
'status',
'creation_date',
'shipment_date',
]
@ -313,7 +316,9 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer(many=True, read_only=True)
quantity = serializers.FloatField()
# TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
quantity = serializers.FloatField(default=1)
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
sale_price_string = serializers.CharField(source='sale_price', read_only=True)

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

@ -4,6 +4,8 @@
{% load i18n %}
{% block form %}
{% default_currency as currency %}
{% settings_value 'PART_SHOW_PRICE_IN_FORMS' as show_price %}
<h4>
{% trans "Step 1 of 2 - Select Part Suppliers" %}
@ -49,7 +51,13 @@
<select class='select' id='id_supplier_part_{{ part.id }}' name="part-supplier-{{ part.id }}">
<option value=''>---------</option>
{% for supplier in part.supplier_parts.all %}
<option value="{{ supplier.id }}"{% if part.order_supplier == supplier.id %} selected="selected"{% endif %}>{{ supplier }}</option>
<option value="{{ supplier.id }}"{% if part.order_supplier == supplier.id %} selected="selected"{% endif %}>
{% if show_price %}
{% call_method supplier 'get_price' part.order_quantity as price %}
{% if price != None %}{% include "price.html" with price=price %}{% else %}{% trans 'No price' %}{% endif %} -
{% endif %}
{{ supplier }}
</option>
{% endfor %}
</select>
</div>

View File

@ -57,8 +57,6 @@ $("#attachment-table").on('click', '.attachment-delete-button', function() {
var url = `/order/purchase-order/attachment/${button.attr('pk')}/delete/`;
console.log("url: " + url);
launchModalForm(url, {
reload: true,
});

View File

@ -110,6 +110,96 @@ class PurchaseOrderTest(OrderTest):
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_po_operations(self):
"""
Test that we can create / edit and delete a PurchaseOrder via the API
"""
n = PurchaseOrder.objects.count()
url = reverse('api-po-list')
# Initially we do not have "add" permission for the PurchaseOrder model,
# so this POST request should return 403
response = self.post(
url,
{
'supplier': 1,
'reference': '123456789-xyz',
'description': 'PO created via the API',
},
expected_code=403
)
# And no new PurchaseOrder objects should have been created
self.assertEqual(PurchaseOrder.objects.count(), n)
# Ok, now let's give this user the correct permission
self.assignRole('purchase_order.add')
# Initially we do not have "add" permission for the PurchaseOrder model,
# so this POST request should return 403
response = self.post(
url,
{
'supplier': 1,
'reference': '123456789-xyz',
'description': 'PO created via the API',
},
expected_code=201
)
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
pk = response.data['pk']
# Try to create a PO with identical reference (should fail!)
response = self.post(
url,
{
'supplier': 1,
'reference': '123456789-xyz',
'description': 'A different description',
},
expected_code=400
)
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
url = reverse('api-po-detail', kwargs={'pk': pk})
# Get detail info!
response = self.get(url)
self.assertEqual(response.data['pk'], pk)
self.assertEqual(response.data['reference'], '123456789-xyz')
# Try to alter (edit) the PurchaseOrder
response = self.patch(
url,
{
'reference': '12345-abc',
},
expected_code=200
)
# Reference should have changed
self.assertEqual(response.data['reference'], '12345-abc')
# Now, let's try to delete it!
# Initially, we do *not* have the required permission!
response = self.delete(url, expected_code=403)
# Now, add the "delete" permission!
self.assignRole("purchase_order.delete")
response = self.delete(url, expected_code=204)
# Number of PurchaseOrder objects should have decreased
self.assertEqual(PurchaseOrder.objects.count(), n)
# And if we try to access the detail view again, it has gone
response = self.get(url, expected_code=404)
class SalesOrderTest(OrderTest):
"""
@ -158,8 +248,6 @@ class SalesOrderTest(OrderTest):
response = self.get(url)
self.assertEqual(response.status_code, 200)
data = response.data
self.assertEqual(data['pk'], 1)
@ -168,6 +256,87 @@ class SalesOrderTest(OrderTest):
url = reverse('api-so-attachment-list')
response = self.get(url)
self.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_so_operations(self):
"""
Test that we can create / edit and delete a SalesOrder via the API
"""
n = SalesOrder.objects.count()
url = reverse('api-so-list')
# Initially we do not have "add" permission for the SalesOrder model,
# so this POST request should return 403 (denied)
response = self.post(
url,
{
'customer': 4,
'reference': '12345',
'description': 'Sales order',
},
expected_code=403,
)
self.assignRole('sales_order.add')
# Now we should be able to create a SalesOrder via the API
response = self.post(
url,
{
'customer': 4,
'reference': '12345',
'description': 'Sales order',
},
expected_code=201
)
# Check that the new order has been created
self.assertEqual(SalesOrder.objects.count(), n + 1)
# Grab the PK for the newly created SalesOrder
pk = response.data['pk']
# Try to create a SO with identical reference (should fail)
response = self.post(
url,
{
'customer': 4,
'reference': '12345',
'description': 'Another sales order',
},
expected_code=400
)
url = reverse('api-so-detail', kwargs={'pk': pk})
# Extract detail info for the SalesOrder
response = self.get(url)
self.assertEqual(response.data['reference'], '12345')
# Try to alter (edit) the SalesOrder
response = self.patch(
url,
{
'reference': '12345-a',
},
expected_code=200
)
# Reference should have changed
self.assertEqual(response.data['reference'], '12345-a')
# Now, let's try to delete this SalesOrder
# Initially, we do not have the required permission
response = self.delete(url, expected_code=403)
self.assignRole('sales_order.delete')
response = self.delete(url, expected_code=204)
# Check that the number of sales orders has decreased
self.assertEqual(SalesOrder.objects.count(), n)
# And the resource should no longer be available
response = self.get(url, expected_code=404)

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():
@ -1004,6 +1010,15 @@ class OrderParts(AjaxView):
return ctx
def get_data(self):
""" enrich respone json data """
data = super().get_data()
# if in selection-phase, add a button to update the prices
if getattr(self, 'form_step', 'select_parts') == 'select_parts':
data['buttons'] = [{'name': 'update_price', 'title': _('Update prices')}] # set buttons
data['hideErrorMessage'] = '1' # hide the error message
return data
def get_suppliers(self):
""" Calculates a list of suppliers which the user will need to create POs for.
This is calculated AFTER the user finishes selecting the parts to order.
@ -1238,9 +1253,10 @@ class OrderParts(AjaxView):
valid = False
if form_step == 'select_parts':
# No errors? Proceed to PO selection form
if part_errors is False:
# No errors? and the price-update button was not used to submit? Proceed to PO selection form
if part_errors is False and 'act-btn_update_price' not in request.POST:
self.ajax_template_name = 'order/order_wizard/select_pos.html'
self.form_step = 'select_purchase_orders' # set step (important for get_data)
else:
self.ajax_template_name = 'order/order_wizard/select_parts.html'

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

@ -6,7 +6,7 @@
name: 'M2x4 LPHS'
description: 'M2x4 low profile head screw'
category: 8
link: www.acme.com/parts/m2x4lphs
link: http://www.acme.com/parts/m2x4lphs
tree_id: 0
purchaseable: True
level: 0
@ -56,6 +56,7 @@
fields:
name: 'C_22N_0805'
description: '22nF capacitor in 0805 package'
purchaseable: true
category: 3
tree_id: 0
level: 0

View File

@ -71,7 +71,8 @@ def migrate_currencies(apps, schema_editor):
count += 1
print(f"Updated {count} SupplierPriceBreak rows")
if count > 0:
print(f"Updated {count} SupplierPriceBreak rows")
def reverse_currencies(apps, schema_editor):
"""

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.4 on 2021-06-21 23:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0067_partinternalpricebreak'),
]
operations = [
migrations.AddConstraint(
model_name='part',
constraint=models.UniqueConstraint(fields=('name', 'IPN', 'revision'), name='unique_part'),
),
]

View File

@ -39,7 +39,7 @@ from InvenTree import helpers
from InvenTree import validators
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string, normalize
from InvenTree.helpers import decimal2string, normalize, decimal2money
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
@ -321,6 +321,9 @@ class Part(MPTTModel):
verbose_name = _("Part")
verbose_name_plural = _("Parts")
ordering = ['name', ]
constraints = [
UniqueConstraint(fields=['name', 'IPN', 'revision'], name='unique_part')
]
class MPTTMeta:
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
@ -379,7 +382,7 @@ class Part(MPTTModel):
logger.info(f"Deleting unused image file '{previous.image}'")
previous.image.delete(save=False)
self.clean()
self.full_clean()
super().save(*args, **kwargs)
@ -642,23 +645,6 @@ class Part(MPTTModel):
'IPN': _('Duplicate IPN not allowed in part settings'),
})
# Part name uniqueness should be case insensitive
try:
parts = Part.objects.exclude(id=self.id).filter(
name__iexact=self.name,
IPN__iexact=self.IPN,
revision__iexact=self.revision)
if parts.exists():
msg = _("Part must be unique for name, IPN and revision")
raise ValidationError({
"name": msg,
"IPN": msg,
"revision": msg,
})
except Part.DoesNotExist:
pass
def clean(self):
"""
Perform cleaning operations for the Part model
@ -671,8 +657,6 @@ class Part(MPTTModel):
super().clean()
self.validate_unique()
if self.trackable:
for part in self.get_used_in().all():
@ -1495,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
@ -1592,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
@ -2428,7 +2414,7 @@ class BomItem(models.Model):
return "{n} x {child} to make {parent}".format(
parent=self.part.full_name,
child=self.sub_part.full_name,
n=helpers.decimal2string(self.quantity))
n=decimal2string(self.quantity))
def available_stock(self):
"""
@ -2512,10 +2498,12 @@ class BomItem(models.Model):
return required
@property
def price_range(self):
def price_range(self, internal=False):
""" 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, internal=use_internal and internal)
if prange is None:
return prange
@ -2523,11 +2511,11 @@ class BomItem(models.Model):
pmin, pmax = prange
if pmin == pmax:
return decimal2string(pmin)
return decimal2money(pmin)
# Convert to better string representation
pmin = decimal2string(pmin)
pmax = decimal2string(pmax)
pmin = decimal2money(pmin)
pmax = decimal2money(pmax)
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)

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()
@ -374,7 +377,7 @@ class PartStarSerializer(InvenTreeModelSerializer):
class BomItemSerializer(InvenTreeModelSerializer):
""" Serializer for BomItem object """
# price_range = serializers.CharField(read_only=True)
price_range = serializers.CharField(read_only=True)
quantity = serializers.FloatField()
@ -489,7 +492,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
'reference',
'sub_part',
'sub_part_detail',
# 'price_range',
'price_range',
'validated',
]

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

@ -16,6 +16,7 @@
{% default_currency as currency %}
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
<form method="post" class="form-horizontal">
{% csrf_token %}
<div class="row">

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,14 +1,20 @@
from rest_framework import status
# -*- coding: utf-8 -*-
import PIL
from django.urls import reverse
from part.models import Part
from stock.models import StockItem
from company.models import Company
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 common.models import InvenTreeSetting
class PartAPITest(InvenTreeAPITestCase):
"""
@ -230,6 +236,18 @@ class PartAPITest(InvenTreeAPITestCase):
response = self.client.get(url, data={'part': 10004})
self.assertEqual(len(response.data), 7)
# Try to post a new object (missing description)
response = self.client.post(
url,
data={
'part': 10000,
'test_name': 'My very first test',
'required': False,
}
)
self.assertEqual(response.status_code, 400)
# Try to post a new object (should succeed)
response = self.client.post(
url,
@ -237,6 +255,7 @@ class PartAPITest(InvenTreeAPITestCase):
'part': 10000,
'test_name': 'New Test',
'required': True,
'description': 'a test description'
},
format='json',
)
@ -248,7 +267,8 @@ class PartAPITest(InvenTreeAPITestCase):
url,
data={
'part': 10004,
'test_name': " newtest"
'test_name': " newtest",
'description': 'dafsdf',
},
format='json',
)
@ -292,6 +312,297 @@ 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):
"""
Test that we can create / edit / delete Part objects via the API
"""
fixtures = [
'category',
'part',
'location',
'bom',
'test_templates',
]
roles = [
'part.change',
'part.add',
'part.delete',
'part_category.change',
'part_category.add',
]
def setUp(self):
super().setUp()
def test_part_operations(self):
n = Part.objects.count()
# Create a part
response = self.client.post(
reverse('api-part-list'),
{
'name': 'my test api part',
'description': 'a part created with the API',
'category': 1,
}
)
self.assertEqual(response.status_code, 201)
pk = response.data['pk']
# Check that a new part has been added
self.assertEqual(Part.objects.count(), n + 1)
part = Part.objects.get(pk=pk)
self.assertEqual(part.name, 'my test api part')
# Edit the part
url = reverse('api-part-detail', kwargs={'pk': pk})
# Let's change the name of the part
response = self.client.patch(url, {
'name': 'a new better name',
})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['pk'], pk)
self.assertEqual(response.data['name'], 'a new better name')
part = Part.objects.get(pk=pk)
# Name has been altered
self.assertEqual(part.name, 'a new better name')
# Part count should not have changed
self.assertEqual(Part.objects.count(), n + 1)
# Now, try to set the name to the *same* value
# 2021-06-22 this test is to check that the "duplicate part" checks don't do strange things
response = self.client.patch(url, {
'name': 'a new better name',
})
self.assertEqual(response.status_code, 200)
# 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
self.assertEqual(Part.objects.count(), n)
def test_duplicates(self):
"""
Check that trying to create 'duplicate' parts results in errors
"""
# Create a part
response = self.client.post(reverse('api-part-list'), {
'name': 'part',
'description': 'description',
'IPN': 'IPN-123',
'category': 1,
'revision': 'A',
})
self.assertEqual(response.status_code, 201)
n = Part.objects.count()
# Check that we cannot create a duplicate in a different category
response = self.client.post(reverse('api-part-list'), {
'name': 'part',
'description': 'description',
'IPN': 'IPN-123',
'category': 2,
'revision': 'A',
})
self.assertEqual(response.status_code, 400)
# Check that only 1 matching part exists
parts = Part.objects.filter(
name='part',
description='description',
IPN='IPN-123'
)
self.assertEqual(parts.count(), 1)
# A new part should *not* have been created
self.assertEqual(Part.objects.count(), n)
# But a different 'revision' *can* be created
response = self.client.post(reverse('api-part-list'), {
'name': 'part',
'description': 'description',
'IPN': 'IPN-123',
'category': 2,
'revision': 'B',
})
self.assertEqual(response.status_code, 201)
self.assertEqual(Part.objects.count(), n + 1)
# Now, check that we cannot *change* an existing part to conflict
pk = response.data['pk']
url = reverse('api-part-detail', kwargs={'pk': pk})
# Attempt to alter the revision code
response = self.client.patch(
url,
{
'revision': 'A',
},
format='json',
)
self.assertEqual(response.status_code, 400)
# But we *can* change it to a unique revision code
response = self.client.patch(
url,
{
'revision': 'C',
}
)
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):
"""
@ -319,6 +630,8 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
# Add a new part
self.part = Part.objects.create(
name='Banana',
description='This is a banana',
category=PartCategory.objects.get(pk=1),
)
# Create some stock items associated with the part

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

@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError
import os
from .models import Part, PartTestTemplate
from .models import Part, PartCategory, PartTestTemplate
from .models import rename_part_image, match_part_names
from .templatetags import inventree_extras
@ -78,6 +78,61 @@ class PartTest(TestCase):
p = Part.objects.get(pk=100)
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")
def test_duplicate(self):
"""
Test that we cannot create a "duplicate" Part
"""
n = Part.objects.count()
cat = PartCategory.objects.get(pk=1)
Part.objects.create(
category=cat,
name='part',
description='description',
IPN='IPN',
revision='A',
)
self.assertEqual(Part.objects.count(), n + 1)
part = Part(
category=cat,
name='part',
description='description',
IPN='IPN',
revision='A',
)
with self.assertRaises(ValidationError):
part.validate_unique()
try:
part.save()
self.assertTrue(False)
except:
pass
self.assertEqual(Part.objects.count(), n + 1)
# But we should be able to create a part with a different revision
part_2 = Part.objects.create(
category=cat,
name='part',
description='description',
IPN='IPN',
revision='B',
)
self.assertEqual(Part.objects.count(), n + 2)
# Now, check that we cannot *change* part_2 to conflict
part_2.revision = 'A'
with self.assertRaises(ValidationError):
part_2.validate_unique()
def test_metadata(self):
self.assertEqual(self.r1.name, 'R_2K2_0805')
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
@ -277,21 +332,24 @@ class PartSettingsTest(TestCase):
"""
# Create a part
Part.objects.create(name='Hello', description='A thing', IPN='IPN123')
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='A')
# Attempt to create a duplicate item (should fail)
with self.assertRaises(ValidationError):
Part.objects.create(name='Hello', description='A thing', IPN='IPN123')
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='A')
part.validate_unique()
# Attempt to create item with duplicate IPN (should be allowed by default)
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
# And attempt again with the same values (should fail)
with self.assertRaises(ValidationError):
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='B')
part.validate_unique()
# Now update the settings so duplicate IPN values are *not* allowed
InvenTreeSetting.set_setting('PART_ALLOW_DUPLICATE_IPN', False, self.user)
with self.assertRaises(ValidationError):
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='C')
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C')
part.full_clean()

View File

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

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

@ -199,7 +199,8 @@ def update_history(apps, schema_editor):
update_count += 1
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries")
if update_count > 0:
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries")
def reverse_update(apps, schema_editor):

View File

@ -26,7 +26,8 @@ def extract_purchase_price(apps, schema_editor):
# Find all the StockItem objects without a purchase_price which point to a PurchaseOrder
items = StockItem.objects.filter(purchase_price=None).exclude(purchase_order=None)
print(f"Found {items.count()} stock items with missing purchase price information")
if items.count() > 0:
print(f"Found {items.count()} stock items with missing purchase price information")
update_count = 0
@ -56,7 +57,8 @@ def extract_purchase_price(apps, schema_editor):
break
print(f"Updated pricing for {update_count} stock items")
if update_count > 0:
print(f"Updated pricing for {update_count} stock items")
def reverse_operation(apps, schema_editor):
"""

View File

@ -350,7 +350,12 @@
<tr>
<td><span class='fas fa-industry'></span></td>
<td>{% trans "Manufacturer" %}</td>
<td><a href="{% url 'company-detail' item.supplier_part.manufacturer_part.manufacturer.id %}">{{ item.supplier_part.manufacturer_part.manufacturer.name }}</a></td>
{% if item.supplier_part.manufacturer_part.manufacturer %}
<td><a href="{% url 'company-detail' item.supplier_part.manufacturer_part.manufacturer.id %}">{{ item.supplier_part.manufacturer_part.manufacturer.name }}</a></td>
{% else %}
<td><i>{% trans "No manufacturer set" %}</i></td>
{% endif %}
</tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>

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

@ -20,6 +20,7 @@
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_FORMS" icon="fa-dollar-sign" %}
{% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" %}
<tr><td colspan='5 '></td></tr>
{% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %}
@ -40,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'>
@ -124,4 +141,8 @@
});
});
$("#import-part").click(function() {
launchModalForm("{% url 'api-part-import' %}?reset", {});
});
{% endblock %}

View File

@ -259,26 +259,19 @@ function loadBomTable(table, options) {
sortable: true,
});
/*
// TODO - Re-introduce the pricing column at a later stage,
// once the pricing has been "fixed"
// O.W. 2020-11-24
cols.push(
{
field: 'price_range',
title: '{% trans "Price" %}',
title: '{% trans "Buy Price" %}',
sortable: true,
formatter: function(value, row, index, field) {
if (value) {
return value;
} else {
return "<span class='warning-msg'>{% trans "No pricing available" %}</span>";
return "<span class='warning-msg'>{% trans 'No pricing available' %}</span>";
}
}
});
*/
cols.push({
field: 'optional',

View File

@ -419,6 +419,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
sub_part_detail: true,
sub_part_trackable: trackable,
},
disablePagination: true,
formatNoMatches: function() {
return '{% trans "No BOM items found" %}';
},
@ -668,6 +669,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
{
field: 'sub_part_detail.stock',
title: '{% trans "Available" %}',
sortable: true,
},
{
field: 'allocated',
@ -687,11 +689,13 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
return makeProgressBar(allocated, required);
},
sorter: function(valA, valB, rowA, rowB) {
// Custom sorting function for progress bars
var aA = sumAllocations(rowA);
var aB = sumAllocations(rowB);
var qA = rowA.quantity;
var qB = rowB.quantity;
var qA = requiredQuantity(rowA);
var qB = requiredQuantity(rowB);
// Handle the case where both numerators are zero
if ((aA == 0) && (aB == 0)) {
@ -711,6 +715,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
return (qA < qB) ? 1 : -1;
}
if (progressA == progressB) return 0;
return (progressA < progressB) ? 1 : -1;
}
},

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) {
@ -603,7 +608,6 @@ function loadStockTable(table, options) {
// REJECTED
if (row.status == {{ StockStatus.REJECTED }}) {
console.log("REJECTED - {{ StockStatus.REJECTED }}");
html += makeIconBadge('fa-times-circle icon-red', '{% trans "Stock item has been rejected" %}');
}
// LOST

View File

@ -134,12 +134,14 @@ $.fn.inventreeTable = function(options) {
var varName = tableName + '-pagesize';
// Pagingation options (can be server-side or client-side as specified by the caller)
options.pagination = true;
options.paginationVAlign = options.paginationVAlign || 'both';
options.pageSize = inventreeLoad(varName, 25);
options.pageList = [25, 50, 100, 250, 'all'];
options.totalField = 'count';
options.dataField = 'results';
if (!options.disablePagination) {
options.pagination = true;
options.paginationVAlign = options.paginationVAlign || 'both';
options.pageSize = inventreeLoad(varName, 25);
options.pageList = [25, 50, 100, 250, 'all'];
options.totalField = 'count';
options.dataField = 'results';
}
// Extract query params
var filters = options.queryParams || options.filters || {};

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