mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into allocated-sort-qty
This commit is contained in:
commit
153be2df13
49
.github/workflows/python.yaml
vendored
Normal file
49
.github/workflows/python.yaml
vendored
Normal 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
3
.gitignore
vendored
@ -35,6 +35,9 @@ local_settings.py
|
||||
*.backup
|
||||
*.old
|
||||
|
||||
# Files used for testing
|
||||
dummy_image.*
|
||||
|
||||
# Sphinx files
|
||||
docs/_build
|
||||
|
||||
|
@ -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,6 +41,10 @@ class InvenTreeAPITestCase(APITestCase):
|
||||
|
||||
if self.superuser:
|
||||
self.user.is_superuser = True
|
||||
|
||||
if self.is_staff:
|
||||
self.user.is_staff = True
|
||||
|
||||
self.user.save()
|
||||
|
||||
for role in self.roles:
|
||||
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
if not hasattr(self, 'instance') or self.instance is None:
|
||||
# No instance exists (we are creating a new one)
|
||||
instance = self.Meta.model(**data)
|
||||
instance.clean()
|
||||
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))
|
||||
|
@ -23,6 +23,7 @@ import moneyed
|
||||
|
||||
import yaml
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.contrib.messages import constants as messages
|
||||
|
||||
|
||||
def _is_true(x):
|
||||
@ -611,3 +612,9 @@ IMPORT_EXPORT_USE_TRANSACTIONS = True
|
||||
INTERNAL_IPS = [
|
||||
'127.0.0.1',
|
||||
]
|
||||
|
||||
MESSAGE_TAGS = {
|
||||
messages.SUCCESS: 'alert alert-block alert-success',
|
||||
messages.ERROR: 'alert alert-block alert-danger',
|
||||
messages.INFO: 'alert alert-block alert-info',
|
||||
}
|
||||
|
@ -718,7 +718,7 @@
|
||||
position:relative;
|
||||
height: auto !important;
|
||||
max-height: calc(100vh - 200px) !important;
|
||||
overflow-y: scroll;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
||||
|
@ -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?
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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 """
|
||||
|
@ -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('/')
|
||||
|
@ -40,6 +40,7 @@ def assign_bom_items(apps, schema_editor):
|
||||
except BomItem.DoesNotExist:
|
||||
pass
|
||||
|
||||
if count_total > 0:
|
||||
print(f"Assigned BomItem for {count_valid}/{count_total} entries")
|
||||
|
||||
|
||||
|
@ -26,6 +26,8 @@ class FileManager:
|
||||
# Fields which would be helpful but are not required
|
||||
OPTIONAL_HEADERS = []
|
||||
|
||||
OPTIONAL_MATCH_HEADERS = []
|
||||
|
||||
EDITABLE_HEADERS = []
|
||||
|
||||
HEADERS = []
|
||||
@ -82,30 +84,17 @@ class FileManager:
|
||||
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()
|
||||
|
||||
|
@ -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,7 +65,7 @@ class UploadFile(forms.Form):
|
||||
return file
|
||||
|
||||
|
||||
class MatchField(forms.Form):
|
||||
class MatchFieldForm(forms.Form):
|
||||
""" Step 2 of FileManagementFormView """
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -103,7 +98,7 @@ 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):
|
||||
@ -131,24 +126,41 @@ class MatchItem(forms.Form):
|
||||
for col in row['data']:
|
||||
# Get column matching
|
||||
col_guess = col['column'].get('guess', None)
|
||||
|
||||
# Create input for required headers
|
||||
if col_guess in file_manager.REQUIRED_HEADERS:
|
||||
# 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
|
||||
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=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.NumberInput(attrs={
|
||||
'name': 'quantity' + str(row['index']),
|
||||
'class': 'numberinput', # form-control',
|
||||
'type': 'number',
|
||||
'min': '0',
|
||||
'step': 'any',
|
||||
'value': clean_decimal(row.get('quantity', '')),
|
||||
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,
|
||||
)
|
||||
|
||||
def get_special_field(self, col_guess, row, file_manager):
|
||||
""" Function to be overriden in inherited forms to add specific form settings """
|
||||
|
||||
return None
|
||||
|
@ -205,6 +205,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'),
|
||||
|
@ -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,7 +126,7 @@ 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()
|
||||
|
||||
@ -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,7 +188,22 @@ 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'):
|
||||
@ -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 """
|
||||
@ -269,6 +284,15 @@ class FileManagementFormView(MultiStepFormView):
|
||||
|
||||
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.
|
||||
@ -375,6 +399,7 @@ class FileManagementFormView(MultiStepFormView):
|
||||
'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 """
|
||||
|
||||
@ -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!')
|
||||
|
@ -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):
|
||||
|
@ -71,6 +71,7 @@ def migrate_currencies(apps, schema_editor):
|
||||
|
||||
count += 1
|
||||
|
||||
if count > 0:
|
||||
print(f"Updated {count} SupplierPriceBreak rows")
|
||||
|
||||
def reverse_currencies(apps, schema_editor):
|
||||
|
@ -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)
|
||||
|
@ -139,13 +139,19 @@
|
||||
|
||||
enableDragAndDrop(
|
||||
"#company-thumb",
|
||||
"{% url 'company-image' company.id %}",
|
||||
"{% url 'api-company-detail' company.id %}",
|
||||
{
|
||||
label: 'image',
|
||||
method: 'PATCH',
|
||||
success: function(data, status, xhr) {
|
||||
|
||||
if (data.image) {
|
||||
$('#company-image').attr('src', data.image);
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
{% if company.image %}
|
||||
|
@ -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')
|
||||
|
||||
|
@ -623,6 +623,7 @@ class SupplierPartEdit(AjaxUpdateView):
|
||||
supplier_part = self.get_object()
|
||||
|
||||
if supplier_part.manufacturer_part:
|
||||
if supplier_part.manufacturer_part.manufacturer:
|
||||
initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id
|
||||
initials['MPN'] = supplier_part.manufacturer_part.MPN
|
||||
|
||||
|
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
@ -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.
|
||||
"""
|
||||
|
@ -10,10 +10,17 @@ from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from mptt.fields import TreeNodeChoiceField
|
||||
|
||||
from djmoney.forms.fields import MoneyField
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
from InvenTree.fields import RoundingDecimalFormField
|
||||
from InvenTree.fields import DatePickerFormField
|
||||
|
||||
from InvenTree.helpers import clean_decimal
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from common.forms import MatchItemForm
|
||||
|
||||
import part.models
|
||||
|
||||
from stock.models import StockLocation
|
||||
@ -291,3 +298,37 @@ class EditSalesOrderAllocationForm(HelperForm):
|
||||
'line',
|
||||
'item',
|
||||
'quantity']
|
||||
|
||||
|
||||
class OrderMatchItemForm(MatchItemForm):
|
||||
""" Override MatchItemForm fields """
|
||||
|
||||
def get_special_field(self, col_guess, row, file_manager):
|
||||
""" Set special fields """
|
||||
|
||||
# set quantity field
|
||||
if 'quantity' in col_guess.lower():
|
||||
return forms.CharField(
|
||||
required=False,
|
||||
widget=forms.NumberInput(attrs={
|
||||
'name': 'quantity' + str(row['index']),
|
||||
'class': 'numberinput',
|
||||
'type': 'number',
|
||||
'min': '0',
|
||||
'step': 'any',
|
||||
'value': clean_decimal(row.get('quantity', '')),
|
||||
})
|
||||
)
|
||||
# set price field
|
||||
elif 'price' in col_guess.lower():
|
||||
return MoneyField(
|
||||
label=_(col_guess),
|
||||
default_currency=InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY'),
|
||||
decimal_places=5,
|
||||
max_digits=19,
|
||||
required=False,
|
||||
default_amount=clean_decimal(row.get('purchase_price', '')),
|
||||
)
|
||||
|
||||
# return default
|
||||
return super().get_special_field(col_guess, row, file_manager)
|
||||
|
@ -17,8 +17,7 @@ from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||
|
||||
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
||||
from part.serializers import PartBriefSerializer
|
||||
from stock.serializers import LocationBriefSerializer
|
||||
from stock.serializers import StockItemSerializer, LocationSerializer
|
||||
from stock.serializers import LocationBriefSerializer, StockItemSerializer, LocationSerializer
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from .models import PurchaseOrderAttachment, SalesOrderAttachment
|
||||
@ -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)
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -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)
|
||||
|
@ -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'
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -71,6 +71,7 @@ def migrate_currencies(apps, schema_editor):
|
||||
|
||||
count += 1
|
||||
|
||||
if count > 0:
|
||||
print(f"Updated {count} SupplierPriceBreak rows")
|
||||
|
||||
def reverse_currencies(apps, schema_editor):
|
||||
|
17
InvenTree/part/migrations/0068_part_unique_part.py
Normal file
17
InvenTree/part/migrations/0068_part_unique_part.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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)
|
||||
|
||||
|
@ -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',
|
||||
]
|
||||
|
||||
|
@ -8,6 +8,15 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class='{{ message.tags }}'>
|
||||
{{ message|safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
|
@ -1,4 +1,7 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% settings_value 'PART_SHOW_IMPORT' as show_import %}
|
||||
|
||||
<ul class='list-group'>
|
||||
|
||||
@ -30,6 +33,15 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% if show_import and user.is_staff and roles.part.add %}
|
||||
<li class='list-group-item {% if tab == "import" %}active{% endif %}' title='{% trans "Import Parts" %}'>
|
||||
<a href='{% url "part-import" %}'>
|
||||
<span class='fas fa-file-upload sidebar-icon'></span>
|
||||
{% trans "Import Parts" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if category %}
|
||||
<li class='list-group-item {% if tab == "parameters" %}active{% endif %}' title='{% trans "Parameters" %}'>
|
||||
<a href='{% url "category-parametric" category.id %}'>
|
||||
|
@ -0,0 +1,89 @@
|
||||
{% extends "part/import_wizard/ajax_part_upload.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block form_alert %}
|
||||
{% if missing_columns and missing_columns|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Missing selections for the following required columns" %}:
|
||||
<br>
|
||||
<ul>
|
||||
{% for col in missing_columns %}
|
||||
<li>{{ col }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if duplicates and duplicates|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
{% block form_content %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "File Fields" %}</th>
|
||||
<th></th>
|
||||
{% for col in form %}
|
||||
<th>
|
||||
<div>
|
||||
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||
{{ col.name }}
|
||||
<button class='btn btn-default btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'>
|
||||
<span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{% trans "Match Fields" %}</td>
|
||||
<td></td>
|
||||
{% for col in form %}
|
||||
<td>
|
||||
{{ col }}
|
||||
{% for duplicate in duplicates %}
|
||||
{% if duplicate == col.value %}
|
||||
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
||||
<b>{% trans "Duplicate selection" %}</b>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for row in rows %}
|
||||
{% with forloop.counter as row_index %}
|
||||
<tr>
|
||||
<td style='width: 32px;'>
|
||||
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row_index }}' style='display: inline; float: left;' title='{% trans "Remove row" %}'>
|
||||
<span row_id='{{ row_index }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td style='text-align: left;'>{{ row_index }}</td>
|
||||
{% for item in row.data %}
|
||||
<td>
|
||||
<input type='hidden' name='row_{{ row_index }}_col_{{ forloop.counter0 }}' value='{{ item }}'/>
|
||||
{{ item }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endblock form_content %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('.fieldselect').select2({
|
||||
width: '100%',
|
||||
matcher: partialMatcher,
|
||||
});
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,84 @@
|
||||
{% extends "part/import_wizard/ajax_part_upload.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block form_alert %}
|
||||
{% if form.errors %}
|
||||
{% endif %}
|
||||
{% if form_errors %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Errors exist in the submitted data" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
{% block form_content %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{% trans "Row" %}</th>
|
||||
{% for col in columns %}
|
||||
|
||||
<th>
|
||||
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||
<input type='hidden' name='col_guess_{{ forloop.counter0 }}' value='{{ col.guess }}'/>
|
||||
{% if col.guess %}
|
||||
{{ col.guess }}
|
||||
{% else %}
|
||||
{{ col.name }}
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr></tr> {% comment %} Dummy row for javascript del_row method {% endcomment %}
|
||||
{% for row in rows %}
|
||||
<tr {% if row.errors %} style='background: #ffeaea;'{% endif %} part-select='#select_part_{{ row.index }}'>
|
||||
<td>
|
||||
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row.index }}' style='display: inline; float: right;' title='{% trans "Remove row" %}'>
|
||||
<span row_id='{{ row.index }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{% add row.index 1 %}
|
||||
</td>
|
||||
{% for item in row.data %}
|
||||
<td>
|
||||
{% if item.column.guess %}
|
||||
{% with row_name=item.column.guess|lower %}
|
||||
{% for field in form.visible_fields %}
|
||||
{% if field.name == row|keyvalue:row_name %}
|
||||
{{ field|as_crispy_field }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{{ item.cell }}
|
||||
{% endif %}
|
||||
<input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endblock form_content %}
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% endblock form_buttons_bottom %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('.bomselect').select2({
|
||||
dropdownAutoWidth: true,
|
||||
matcher: partialMatcher,
|
||||
});
|
||||
|
||||
$('.currencyselect').select2({
|
||||
dropdownAutoWidth: true,
|
||||
});
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,33 @@
|
||||
{% extends "modal_form.html" %}
|
||||
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block form %}
|
||||
|
||||
{% if roles.part.change %}
|
||||
|
||||
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
|
||||
{% if description %}- {{ description }}{% endif %}</p>
|
||||
|
||||
{% block form_alert %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
|
||||
{{ wizard.management_form }}
|
||||
{% block form_content %}
|
||||
{% crispy wizard.form %}
|
||||
{% endblock form_content %}
|
||||
</table>
|
||||
|
||||
{% else %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Unsuffitient privileges." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
@ -0,0 +1,99 @@
|
||||
{% extends "part/import_wizard/part_upload.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block form_alert %}
|
||||
{% if missing_columns and missing_columns|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Missing selections for the following required columns" %}:
|
||||
<br>
|
||||
<ul>
|
||||
{% for col in missing_columns %}
|
||||
<li>{{ col }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if duplicates and duplicates|length > 0 %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Duplicate selections found, see below. Fix them then retry submitting." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
{% block form_buttons_top %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
|
||||
{% endblock form_buttons_top %}
|
||||
|
||||
{% block form_content %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "File Fields" %}</th>
|
||||
<th></th>
|
||||
{% for col in form %}
|
||||
<th>
|
||||
<div>
|
||||
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||
{{ col.name }}
|
||||
<button class='btn btn-default btn-remove' onClick='removeColFromBomWizard()' id='del_col_{{ forloop.counter0 }}' style='display: inline; float: right;' title='{% trans "Remove column" %}'>
|
||||
<span col_id='{{ forloop.counter0 }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{% trans "Match Fields" %}</td>
|
||||
<td></td>
|
||||
{% for col in form %}
|
||||
<td>
|
||||
{{ col }}
|
||||
{% for duplicate in duplicates %}
|
||||
{% if duplicate == col.value %}
|
||||
<div class='alert alert-danger alert-block text-center' role='alert' style='padding:2px; margin-top:6px; margin-bottom:2px'>
|
||||
<b>{% trans "Duplicate selection" %}</b>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for row in rows %}
|
||||
{% with forloop.counter as row_index %}
|
||||
<tr>
|
||||
<td style='width: 32px;'>
|
||||
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row_index }}' style='display: inline; float: left;' title='{% trans "Remove row" %}'>
|
||||
<span row_id='{{ row_index }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td style='text-align: left;'>{{ row_index }}</td>
|
||||
{% for item in row.data %}
|
||||
<td>
|
||||
<input type='hidden' name='row_{{ row_index }}_col_{{ forloop.counter0 }}' value='{{ item }}'/>
|
||||
{{ item }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endblock form_content %}
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% endblock form_buttons_bottom %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('.fieldselect').select2({
|
||||
width: '100%',
|
||||
matcher: partialMatcher,
|
||||
});
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,91 @@
|
||||
{% extends "part/import_wizard/part_upload.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block form_alert %}
|
||||
{% if form.errors %}
|
||||
{% endif %}
|
||||
{% if form_errors %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Errors exist in the submitted data" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
{% block form_buttons_top %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="save btn btn-default">{% trans "Submit Selections" %}</button>
|
||||
{% endblock form_buttons_top %}
|
||||
|
||||
{% block form_content %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{% trans "Row" %}</th>
|
||||
{% for col in columns %}
|
||||
|
||||
<th>
|
||||
<input type='hidden' name='col_name_{{ forloop.counter0 }}' value='{{ col.name }}'/>
|
||||
<input type='hidden' name='col_guess_{{ forloop.counter0 }}' value='{{ col.guess }}'/>
|
||||
{% if col.guess %}
|
||||
{{ col.guess }}
|
||||
{% else %}
|
||||
{{ col.name }}
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr></tr> {% comment %} Dummy row for javascript del_row method {% endcomment %}
|
||||
{% for row in rows %}
|
||||
<tr {% if row.errors %} style='background: #ffeaea;'{% endif %} part-select='#select_part_{{ row.index }}'>
|
||||
<td>
|
||||
<button class='btn btn-default btn-remove' onClick='removeRowFromBomWizard()' id='del_row_{{ row.index }}' style='display: inline; float: right;' title='{% trans "Remove row" %}'>
|
||||
<span row_id='{{ row.index }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{% add row.index 1 %}
|
||||
</td>
|
||||
{% for item in row.data %}
|
||||
<td>
|
||||
{% if item.column.guess %}
|
||||
{% with row_name=item.column.guess|lower %}
|
||||
{% for field in form.visible_fields %}
|
||||
{% if field.name == row|keyvalue:row_name %}
|
||||
{{ field|as_crispy_field }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{{ item.cell }}
|
||||
{% endif %}
|
||||
<input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endblock form_content %}
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% endblock form_buttons_bottom %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
$('.bomselect').select2({
|
||||
dropdownAutoWidth: true,
|
||||
matcher: partialMatcher,
|
||||
});
|
||||
|
||||
$('.currencyselect').select2({
|
||||
dropdownAutoWidth: true,
|
||||
});
|
||||
|
||||
{% endblock %}
|
61
InvenTree/part/templates/part/import_wizard/part_upload.html
Normal file
61
InvenTree/part/templates/part/import_wizard/part_upload.html
Normal file
@ -0,0 +1,61 @@
|
||||
{% extends "part/category.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include 'part/category_navbar.html' with tab='import' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block category_content %}
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% trans "Import Parts from File" %}
|
||||
{{ wizard.form.media }}
|
||||
</h4>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if roles.part.change %}
|
||||
|
||||
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
|
||||
{% if description %}- {{ description }}{% endif %}</p>
|
||||
|
||||
{% block form_alert %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block form_buttons_top %}
|
||||
{% endblock form_buttons_top %}
|
||||
|
||||
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
|
||||
{{ wizard.management_form }}
|
||||
{% block form_content %}
|
||||
{% crispy wizard.form %}
|
||||
{% endblock form_content %}
|
||||
</table>
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="save btn btn-default">{% trans "Upload File" %}</button>
|
||||
</form>
|
||||
{% endblock form_buttons_bottom %}
|
||||
|
||||
{% else %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Unsuffitient privileges." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
{% endblock %}
|
@ -16,6 +16,7 @@
|
||||
{% default_currency as currency %}
|
||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||
|
||||
|
||||
<form method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
|
@ -239,13 +239,21 @@
|
||||
|
||||
enableDragAndDrop(
|
||||
'#part-thumb',
|
||||
"{% url 'part-image-upload' part.id %}",
|
||||
"{% url 'api-part-detail' part.id %}",
|
||||
{
|
||||
label: 'image',
|
||||
method: 'PATCH',
|
||||
success: function(data, status, xhr) {
|
||||
|
||||
// If image / thumbnail data present, live update
|
||||
if (data.image) {
|
||||
$('#part-image').attr('src', data.image);
|
||||
} else {
|
||||
// Otherwise, reload the page
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
$("#show-qr-code").click(function() {
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
|
@ -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()
|
||||
|
@ -128,6 +128,10 @@ part_urls = [
|
||||
# Create a new part
|
||||
url(r'^new/?', views.PartCreate.as_view(), name='part-create'),
|
||||
|
||||
# Upload a part
|
||||
url(r'^import/', views.PartImport.as_view(), name='part-import'),
|
||||
url(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'),
|
||||
|
||||
# Create a new BOM item
|
||||
url(r'^bom/new/?', views.BomItemCreate.as_view(), name='bom-item-create'),
|
||||
|
||||
|
@ -17,6 +17,7 @@ from django.views.generic import DetailView, ListView, FormView, UpdateView
|
||||
from django.forms.models import model_to_dict
|
||||
from django.forms import HiddenInput, CheckboxInput
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
@ -40,6 +41,10 @@ from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from company.models import SupplierPart
|
||||
from common.files import FileManager
|
||||
from common.views import FileManagementFormView, FileManagementAjaxView
|
||||
|
||||
from stock.models import StockLocation
|
||||
|
||||
import common.settings as inventree_settings
|
||||
|
||||
@ -719,6 +724,168 @@ class PartCreate(AjaxCreateView):
|
||||
return initials
|
||||
|
||||
|
||||
class PartImport(FileManagementFormView):
|
||||
''' Part: Upload file, match to fields and import parts(using multi-Step form) '''
|
||||
permission_required = 'part.add'
|
||||
|
||||
class PartFileManager(FileManager):
|
||||
REQUIRED_HEADERS = [
|
||||
'Name',
|
||||
'Description',
|
||||
]
|
||||
|
||||
OPTIONAL_MATCH_HEADERS = [
|
||||
'Category',
|
||||
'default_location',
|
||||
'default_supplier',
|
||||
]
|
||||
|
||||
OPTIONAL_HEADERS = [
|
||||
'Keywords',
|
||||
'IPN',
|
||||
'Revision',
|
||||
'Link',
|
||||
'default_expiry',
|
||||
'minimum_stock',
|
||||
'Units',
|
||||
'Notes',
|
||||
]
|
||||
|
||||
name = 'part'
|
||||
form_steps_template = [
|
||||
'part/import_wizard/part_upload.html',
|
||||
'part/import_wizard/match_fields.html',
|
||||
'part/import_wizard/match_references.html',
|
||||
]
|
||||
form_steps_description = [
|
||||
_("Upload File"),
|
||||
_("Match Fields"),
|
||||
_("Match References"),
|
||||
]
|
||||
|
||||
form_field_map = {
|
||||
'name': 'name',
|
||||
'description': 'description',
|
||||
'keywords': 'keywords',
|
||||
'ipn': 'ipn',
|
||||
'revision': 'revision',
|
||||
'link': 'link',
|
||||
'default_expiry': 'default_expiry',
|
||||
'minimum_stock': 'minimum_stock',
|
||||
'units': 'units',
|
||||
'notes': 'notes',
|
||||
'category': 'category',
|
||||
'default_location': 'default_location',
|
||||
'default_supplier': 'default_supplier',
|
||||
}
|
||||
file_manager_class = PartFileManager
|
||||
|
||||
def get_field_selection(self):
|
||||
""" Fill the form fields for step 3 """
|
||||
# fetch available elements
|
||||
self.allowed_items = {}
|
||||
self.matches = {}
|
||||
|
||||
self.allowed_items['Category'] = PartCategory.objects.all()
|
||||
self.matches['Category'] = ['name__contains']
|
||||
self.allowed_items['default_location'] = StockLocation.objects.all()
|
||||
self.matches['default_location'] = ['name__contains']
|
||||
self.allowed_items['default_supplier'] = SupplierPart.objects.all()
|
||||
self.matches['default_supplier'] = ['SKU__contains']
|
||||
|
||||
# setup
|
||||
self.file_manager.setup()
|
||||
# collect submitted column indexes
|
||||
col_ids = {}
|
||||
for col in self.file_manager.HEADERS:
|
||||
index = self.get_column_index(col)
|
||||
if index >= 0:
|
||||
col_ids[col] = index
|
||||
|
||||
# parse all rows
|
||||
for row in self.rows:
|
||||
# check each submitted column
|
||||
for idx in col_ids:
|
||||
data = row['data'][col_ids[idx]]['cell']
|
||||
|
||||
if idx in self.file_manager.OPTIONAL_MATCH_HEADERS:
|
||||
try:
|
||||
exact_match = self.allowed_items[idx].get(**{a: data for a in self.matches[idx]})
|
||||
except (ValueError, self.allowed_items[idx].model.DoesNotExist, self.allowed_items[idx].model.MultipleObjectsReturned):
|
||||
exact_match = None
|
||||
|
||||
row['match_options_' + idx] = self.allowed_items[idx]
|
||||
row['match_' + idx] = exact_match
|
||||
continue
|
||||
|
||||
# general fields
|
||||
row[idx.lower()] = data
|
||||
|
||||
def done(self, form_list, **kwargs):
|
||||
""" Create items """
|
||||
items = self.get_clean_items()
|
||||
|
||||
import_done = 0
|
||||
import_error = []
|
||||
|
||||
# Create Part instances
|
||||
for part_data in items.values():
|
||||
|
||||
# set related parts
|
||||
optional_matches = {}
|
||||
for idx in self.file_manager.OPTIONAL_MATCH_HEADERS:
|
||||
if idx.lower() in part_data:
|
||||
try:
|
||||
optional_matches[idx] = self.allowed_items[idx].get(pk=int(part_data[idx.lower()]))
|
||||
except (ValueError, self.allowed_items[idx].model.DoesNotExist, self.allowed_items[idx].model.MultipleObjectsReturned):
|
||||
optional_matches[idx] = None
|
||||
else:
|
||||
optional_matches[idx] = None
|
||||
|
||||
# add part
|
||||
new_part = Part(
|
||||
name=part_data.get('name', ''),
|
||||
description=part_data.get('description', ''),
|
||||
keywords=part_data.get('keywords', None),
|
||||
IPN=part_data.get('ipn', None),
|
||||
revision=part_data.get('revision', None),
|
||||
link=part_data.get('link', None),
|
||||
default_expiry=part_data.get('default_expiry', 0),
|
||||
minimum_stock=part_data.get('minimum_stock', 0),
|
||||
units=part_data.get('units', None),
|
||||
notes=part_data.get('notes', None),
|
||||
category=optional_matches['Category'],
|
||||
default_location=optional_matches['default_location'],
|
||||
default_supplier=optional_matches['default_supplier'],
|
||||
)
|
||||
try:
|
||||
new_part.save()
|
||||
import_done += 1
|
||||
except ValidationError as _e:
|
||||
import_error.append(', '.join(set(_e.messages)))
|
||||
|
||||
# Set alerts
|
||||
if import_done:
|
||||
alert = f"<strong>{_('Part-Import')}</strong><br>{_('Imported {n} parts').format(n=import_done)}"
|
||||
messages.success(self.request, alert)
|
||||
if import_error:
|
||||
error_text = '\n'.join([f'<li><strong>x{import_error.count(a)}</strong>: {a}</li>' for a in set(import_error)])
|
||||
messages.error(self.request, f"<strong>{_('Some errors occured:')}</strong><br><ul>{error_text}</ul>")
|
||||
|
||||
return HttpResponseRedirect(reverse('part-index'))
|
||||
|
||||
|
||||
class PartImportAjax(FileManagementAjaxView, PartImport):
|
||||
ajax_form_steps_template = [
|
||||
'part/import_wizard/ajax_part_upload.html',
|
||||
'part/import_wizard/ajax_match_fields.html',
|
||||
'part/import_wizard/ajax_match_references.html',
|
||||
]
|
||||
|
||||
def validate(self, obj, form, **kwargs):
|
||||
return PartImport.validate(self, self.steps.current, form, **kwargs)
|
||||
|
||||
|
||||
class PartNotes(UpdateView):
|
||||
""" View for editing the 'notes' field of a Part object.
|
||||
Presents a live markdown editor.
|
||||
@ -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
|
||||
|
@ -199,6 +199,7 @@ def update_history(apps, schema_editor):
|
||||
update_count += 1
|
||||
|
||||
|
||||
if update_count > 0:
|
||||
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries")
|
||||
|
||||
|
||||
|
@ -26,6 +26,7 @@ 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)
|
||||
|
||||
if items.count() > 0:
|
||||
print(f"Found {items.count()} stock items with missing purchase price information")
|
||||
|
||||
update_count = 0
|
||||
@ -56,6 +57,7 @@ def extract_purchase_price(apps, schema_editor):
|
||||
|
||||
break
|
||||
|
||||
if update_count > 0:
|
||||
print(f"Updated pricing for {update_count} stock items")
|
||||
|
||||
def reverse_operation(apps, schema_editor):
|
||||
|
@ -350,7 +350,12 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-industry'></span></td>
|
||||
<td>{% trans "Manufacturer" %}</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>
|
||||
|
@ -354,15 +354,17 @@ 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(
|
||||
|
@ -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 %}
|
@ -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',
|
||||
|
@ -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;
|
||||
}
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
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 || {};
|
||||
|
@ -13,7 +13,7 @@ class UsersConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
|
||||
if canAppAccessDatabase():
|
||||
if canAppAccessDatabase(allow_test=True):
|
||||
|
||||
try:
|
||||
self.assign_permissions()
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user