mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-13 13:07:37 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue2279
This commit is contained in:
@@ -106,12 +106,12 @@ class InvenTreeAPITestCase(APITestCase):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def post(self, url, data, expected_code=None):
|
def post(self, url, data, expected_code=None, format='json'):
|
||||||
"""
|
"""
|
||||||
Issue a POST request
|
Issue a POST request
|
||||||
"""
|
"""
|
||||||
|
|
||||||
response = self.client.post(url, data=data, format='json')
|
response = self.client.post(url, data=data, format=format)
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
self.assertEqual(response.status_code, expected_code)
|
self.assertEqual(response.status_code, expected_code)
|
||||||
@@ -130,12 +130,12 @@ class InvenTreeAPITestCase(APITestCase):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def patch(self, url, data, files=None, expected_code=None):
|
def patch(self, url, data, expected_code=None, format='json'):
|
||||||
"""
|
"""
|
||||||
Issue a PATCH request
|
Issue a PATCH request
|
||||||
"""
|
"""
|
||||||
|
|
||||||
response = self.client.patch(url, data=data, files=files, format='json')
|
response = self.client.patch(url, data=data, format=format)
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
self.assertEqual(response.status_code, expected_code)
|
self.assertEqual(response.status_code, expected_code)
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class InvenTreeConfig(AppConfig):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from djmoney.contrib.exchange.models import ExchangeBackend
|
from djmoney.contrib.exchange.models import ExchangeBackend
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from InvenTree.tasks import update_exchange_rates
|
from InvenTree.tasks import update_exchange_rates
|
||||||
from common.settings import currency_code_default
|
from common.settings import currency_code_default
|
||||||
except AppRegistryNotReady:
|
except AppRegistryNotReady:
|
||||||
@@ -115,23 +115,18 @@ class InvenTreeConfig(AppConfig):
|
|||||||
|
|
||||||
last_update = backend.last_update
|
last_update = backend.last_update
|
||||||
|
|
||||||
if last_update is not None:
|
if last_update is None:
|
||||||
delta = datetime.now().date() - last_update.date()
|
|
||||||
if delta > timedelta(days=1):
|
|
||||||
print(f"Last update was {last_update}")
|
|
||||||
update = True
|
|
||||||
else:
|
|
||||||
# Never been updated
|
# Never been updated
|
||||||
print("Exchange backend has never been updated")
|
logger.info("Exchange backend has never been updated")
|
||||||
update = True
|
update = True
|
||||||
|
|
||||||
# Backend currency has changed?
|
# Backend currency has changed?
|
||||||
if not base_currency == backend.base_currency:
|
if not base_currency == backend.base_currency:
|
||||||
print(f"Base currency changed from {backend.base_currency} to {base_currency}")
|
logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}")
|
||||||
update = True
|
update = True
|
||||||
|
|
||||||
except (ExchangeBackend.DoesNotExist):
|
except (ExchangeBackend.DoesNotExist):
|
||||||
print("Exchange backend not found - updating")
|
logger.info("Exchange backend not found - updating")
|
||||||
update = True
|
update = True
|
||||||
|
|
||||||
except:
|
except:
|
||||||
@@ -139,4 +134,7 @@ class InvenTreeConfig(AppConfig):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if update:
|
if update:
|
||||||
|
try:
|
||||||
update_exchange_rates()
|
update_exchange_rates()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating exchange rates: {e}")
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import certifi
|
||||||
|
import ssl
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
from common.settings import currency_code_default, currency_codes
|
from common.settings import currency_code_default, currency_codes
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import URLError
|
||||||
|
|
||||||
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
|
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
|
||||||
from django.db.utils import OperationalError
|
from django.db.utils import OperationalError
|
||||||
@@ -24,6 +28,22 @@ class InvenTreeExchange(SimpleExchangeBackend):
|
|||||||
return {
|
return {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_response(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Custom code to get response from server.
|
||||||
|
Note: Adds a 5-second timeout
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = self.get_url(**kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
context = ssl.create_default_context(cafile=certifi.where())
|
||||||
|
response = urlopen(url, timeout=5, context=context)
|
||||||
|
return response.read()
|
||||||
|
except:
|
||||||
|
# Returning None here will raise an error upstream
|
||||||
|
return None
|
||||||
|
|
||||||
def update_rates(self, base_currency=currency_code_default()):
|
def update_rates(self, base_currency=currency_code_default()):
|
||||||
|
|
||||||
symbols = ','.join(currency_codes())
|
symbols = ','.join(currency_codes())
|
||||||
@@ -31,7 +51,7 @@ class InvenTreeExchange(SimpleExchangeBackend):
|
|||||||
try:
|
try:
|
||||||
super().update_rates(base=base_currency, symbols=symbols)
|
super().update_rates(base=base_currency, symbols=symbols)
|
||||||
# catch connection errors
|
# catch connection errors
|
||||||
except (HTTPError, URLError):
|
except URLError:
|
||||||
print('Encountered connection error while updating')
|
print('Encountered connection error while updating')
|
||||||
except OperationalError as e:
|
except OperationalError as e:
|
||||||
if 'SerializationFailure' in e.__cause__.__class__.__name__:
|
if 'SerializationFailure' in e.__cause__.__class__.__name__:
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ def WrapWithQuotes(text, quote='"'):
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def MakeBarcode(object_name, object_pk, object_data={}, **kwargs):
|
def MakeBarcode(object_name, object_pk, object_data=None, **kwargs):
|
||||||
""" Generate a string for a barcode. Adds some global InvenTree parameters.
|
""" Generate a string for a barcode. Adds some global InvenTree parameters.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -327,6 +327,8 @@ def MakeBarcode(object_name, object_pk, object_data={}, **kwargs):
|
|||||||
Returns:
|
Returns:
|
||||||
json string of the supplied data plus some other data
|
json string of the supplied data plus some other data
|
||||||
"""
|
"""
|
||||||
|
if object_data is None:
|
||||||
|
object_data = {}
|
||||||
|
|
||||||
url = kwargs.get('url', False)
|
url = kwargs.get('url', False)
|
||||||
brief = kwargs.get('brief', True)
|
brief = kwargs.get('brief', True)
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ class AuthRequiredMiddleware(object):
|
|||||||
|
|
||||||
except Token.DoesNotExist:
|
except Token.DoesNotExist:
|
||||||
logger.warning(f"Access denied for unknown token {token_key}")
|
logger.warning(f"Access denied for unknown token {token_key}")
|
||||||
pass
|
|
||||||
|
|
||||||
# No authorization was found for the request
|
# No authorization was found for the request
|
||||||
if not authorized:
|
if not authorized:
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ def isInTestMode():
|
|||||||
Returns True if the database is in testing mode
|
Returns True if the database is in testing mode
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if 'test' in sys.argv:
|
return 'test' in sys.argv
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
def isImportingData():
|
||||||
|
"""
|
||||||
|
Returns True if the database is currently importing data,
|
||||||
|
e.g. 'loaddata' command is performed
|
||||||
|
"""
|
||||||
|
|
||||||
|
return 'loaddata' in sys.argv
|
||||||
|
|
||||||
|
|
||||||
def canAppAccessDatabase(allow_test=False):
|
def canAppAccessDatabase(allow_test=False):
|
||||||
|
|||||||
@@ -328,4 +328,7 @@ class InvenTreeDecimalField(serializers.FloatField):
|
|||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
|
|
||||||
# Convert the value to a string, and then a decimal
|
# Convert the value to a string, and then a decimal
|
||||||
|
try:
|
||||||
return Decimal(str(data))
|
return Decimal(str(data))
|
||||||
|
except:
|
||||||
|
raise serializers.ValidationError(_("Invalid value"))
|
||||||
|
|||||||
@@ -172,12 +172,6 @@ if MEDIA_ROOT is None:
|
|||||||
print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined")
|
print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Options for django-maintenance-mode : https://pypi.org/project/django-maintenance-mode/
|
|
||||||
MAINTENANCE_MODE_STATE_FILE_PATH = os.path.join(
|
|
||||||
config_dir,
|
|
||||||
'maintenance_mode_state.txt',
|
|
||||||
)
|
|
||||||
|
|
||||||
# List of allowed hosts (default = allow all)
|
# List of allowed hosts (default = allow all)
|
||||||
ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*'])
|
ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*'])
|
||||||
|
|
||||||
@@ -870,6 +864,7 @@ MARKDOWNIFY_BLEACH = False
|
|||||||
|
|
||||||
# Maintenance mode
|
# Maintenance mode
|
||||||
MAINTENANCE_MODE_RETRY_AFTER = 60
|
MAINTENANCE_MODE_RETRY_AFTER = 60
|
||||||
|
MAINTENANCE_MODE_STATE_BACKEND = 'maintenance_mode.backends.DefaultStorageBackend'
|
||||||
|
|
||||||
# Are plugins enabled?
|
# Are plugins enabled?
|
||||||
PLUGINS_ENABLED = _is_true(get_setting(
|
PLUGINS_ENABLED = _is_true(get_setting(
|
||||||
|
|||||||
@@ -269,10 +269,13 @@ def update_exchange_rates():
|
|||||||
|
|
||||||
logger.info(f"Using base currency '{base}'")
|
logger.info(f"Using base currency '{base}'")
|
||||||
|
|
||||||
|
try:
|
||||||
backend.update_rates(base_currency=base)
|
backend.update_rates(base_currency=base)
|
||||||
|
|
||||||
# Remove any exchange rates which are not in the provided currencies
|
# Remove any exchange rates which are not in the provided currencies
|
||||||
Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete()
|
Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating exchange rates: {e}")
|
||||||
|
|
||||||
|
|
||||||
def send_email(subject, body, recipients, from_email=None, html_message=None):
|
def send_email(subject, body, recipients, from_email=None, html_message=None):
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
Custom field validators for InvenTree
|
Custom field validators for InvenTree
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -115,26 +117,28 @@ def validate_tree_name(value):
|
|||||||
|
|
||||||
|
|
||||||
def validate_overage(value):
|
def validate_overage(value):
|
||||||
""" Validate that a BOM overage string is properly formatted.
|
"""
|
||||||
|
Validate that a BOM overage string is properly formatted.
|
||||||
|
|
||||||
An overage string can look like:
|
An overage string can look like:
|
||||||
|
|
||||||
- An integer number ('1' / 3 / 4)
|
- An integer number ('1' / 3 / 4)
|
||||||
|
- A decimal number ('0.123')
|
||||||
- A percentage ('5%' / '10 %')
|
- A percentage ('5%' / '10 %')
|
||||||
"""
|
"""
|
||||||
|
|
||||||
value = str(value).lower().strip()
|
value = str(value).lower().strip()
|
||||||
|
|
||||||
# First look for a simple integer value
|
# First look for a simple numerical value
|
||||||
try:
|
try:
|
||||||
i = int(value)
|
i = Decimal(value)
|
||||||
|
|
||||||
if i < 0:
|
if i < 0:
|
||||||
raise ValidationError(_("Overage value must not be negative"))
|
raise ValidationError(_("Overage value must not be negative"))
|
||||||
|
|
||||||
# Looks like an integer!
|
# Looks like a number
|
||||||
return True
|
return True
|
||||||
except ValueError:
|
except (ValueError, InvalidOperation):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Now look for a percentage value
|
# Now look for a percentage value
|
||||||
@@ -155,7 +159,7 @@ def validate_overage(value):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("Overage must be an integer value or a percentage")
|
_("Invalid value for overage")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ import common.models
|
|||||||
INVENTREE_SW_VERSION = "0.6.0 dev"
|
INVENTREE_SW_VERSION = "0.6.0 dev"
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 23
|
INVENTREE_API_VERSION = 24
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||||
|
|
||||||
|
v24 -> 2022-02-10
|
||||||
|
- Adds API endpoint for deleting (cancelling) build order outputs
|
||||||
|
|
||||||
v23 -> 2022-02-02
|
v23 -> 2022-02-02
|
||||||
- Adds API endpoints for managing plugin classes
|
- Adds API endpoints for managing plugin classes
|
||||||
- Adds API endpoints for managing plugin settings
|
- Adds API endpoints for managing plugin settings
|
||||||
|
|||||||
@@ -109,14 +109,14 @@ class BarcodeScan(APIView):
|
|||||||
# No plugin is found!
|
# No plugin is found!
|
||||||
# However, the hash of the barcode may still be associated with a StockItem!
|
# However, the hash of the barcode may still be associated with a StockItem!
|
||||||
else:
|
else:
|
||||||
hash = hash_barcode(barcode_data)
|
result_hash = hash_barcode(barcode_data)
|
||||||
|
|
||||||
response['hash'] = hash
|
response['hash'] = result_hash
|
||||||
response['plugin'] = None
|
response['plugin'] = None
|
||||||
|
|
||||||
# Try to look for a matching StockItem
|
# Try to look for a matching StockItem
|
||||||
try:
|
try:
|
||||||
item = StockItem.objects.get(uid=hash)
|
item = StockItem.objects.get(uid=result_hash)
|
||||||
serializer = StockItemSerializer(item, part_detail=True, location_detail=True, supplier_part_detail=True)
|
serializer = StockItemSerializer(item, part_detail=True, location_detail=True, supplier_part_detail=True)
|
||||||
response['stockitem'] = serializer.data
|
response['stockitem'] = serializer.data
|
||||||
response['url'] = reverse('stock-item-detail', kwargs={'pk': item.id})
|
response['url'] = reverse('stock-item-detail', kwargs={'pk': item.id})
|
||||||
@@ -182,8 +182,8 @@ class BarcodeAssign(APIView):
|
|||||||
# Matching plugin was found
|
# Matching plugin was found
|
||||||
if plugin is not None:
|
if plugin is not None:
|
||||||
|
|
||||||
hash = plugin.hash()
|
result_hash = plugin.hash()
|
||||||
response['hash'] = hash
|
response['hash'] = result_hash
|
||||||
response['plugin'] = plugin.name
|
response['plugin'] = plugin.name
|
||||||
|
|
||||||
# Ensure that the barcode does not already match a database entry
|
# Ensure that the barcode does not already match a database entry
|
||||||
@@ -208,14 +208,14 @@ class BarcodeAssign(APIView):
|
|||||||
match_found = True
|
match_found = True
|
||||||
|
|
||||||
else:
|
else:
|
||||||
hash = hash_barcode(barcode_data)
|
result_hash = hash_barcode(barcode_data)
|
||||||
|
|
||||||
response['hash'] = hash
|
response['hash'] = result_hash
|
||||||
response['plugin'] = None
|
response['plugin'] = None
|
||||||
|
|
||||||
# Lookup stock item by hash
|
# Lookup stock item by hash
|
||||||
try:
|
try:
|
||||||
item = StockItem.objects.get(uid=hash)
|
item = StockItem.objects.get(uid=result_hash)
|
||||||
response['error'] = _('Barcode hash already matches Stock Item')
|
response['error'] = _('Barcode hash already matches Stock Item')
|
||||||
match_found = True
|
match_found = True
|
||||||
except StockItem.DoesNotExist:
|
except StockItem.DoesNotExist:
|
||||||
|
|||||||
@@ -124,12 +124,12 @@ class BarcodeAPITest(APITestCase):
|
|||||||
|
|
||||||
self.assertIn('success', data)
|
self.assertIn('success', data)
|
||||||
|
|
||||||
hash = data['hash']
|
result_hash = data['hash']
|
||||||
|
|
||||||
# Read the item out from the database again
|
# Read the item out from the database again
|
||||||
item = StockItem.objects.get(pk=522)
|
item = StockItem.objects.get(pk=522)
|
||||||
|
|
||||||
self.assertEqual(hash, item.uid)
|
self.assertEqual(result_hash, item.uid)
|
||||||
|
|
||||||
# Ensure that the same UID cannot be assigned to a different stock item!
|
# Ensure that the same UID cannot be assigned to a different stock item!
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
|
|||||||
@@ -241,6 +241,29 @@ class BuildOutputComplete(generics.CreateAPIView):
|
|||||||
|
|
||||||
serializer_class = build.serializers.BuildOutputCompleteSerializer
|
serializer_class = build.serializers.BuildOutputCompleteSerializer
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
ctx = super().get_serializer_context()
|
||||||
|
|
||||||
|
ctx['request'] = self.request
|
||||||
|
ctx['to_complete'] = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class BuildOutputDelete(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for deleting multiple build outputs
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = Build.objects.none()
|
||||||
|
|
||||||
|
serializer_class = build.serializers.BuildOutputDeleteSerializer
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
|
|
||||||
@@ -432,6 +455,7 @@ build_api_urls = [
|
|||||||
url(r'^(?P<pk>\d+)/', include([
|
url(r'^(?P<pk>\d+)/', include([
|
||||||
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
|
||||||
url(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
|
url(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'),
|
||||||
|
url(r'^delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'),
|
||||||
url(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
|
url(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
|
||||||
url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
|
url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
|
||||||
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
|
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
|
||||||
|
|||||||
@@ -59,30 +59,6 @@ class BuildOutputCreateForm(HelperForm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class BuildOutputDeleteForm(HelperForm):
|
|
||||||
"""
|
|
||||||
Form for deleting a build output.
|
|
||||||
"""
|
|
||||||
|
|
||||||
confirm = forms.BooleanField(
|
|
||||||
required=False,
|
|
||||||
label=_('Confirm'),
|
|
||||||
help_text=_('Confirm deletion of build output')
|
|
||||||
)
|
|
||||||
|
|
||||||
output_id = forms.IntegerField(
|
|
||||||
required=True,
|
|
||||||
widget=forms.HiddenInput()
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Build
|
|
||||||
fields = [
|
|
||||||
'confirm',
|
|
||||||
'output_id',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class CancelBuildForm(HelperForm):
|
class CancelBuildForm(HelperForm):
|
||||||
""" Form for cancelling a build """
|
""" Form for cancelling a build """
|
||||||
|
|
||||||
|
|||||||
@@ -437,6 +437,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
def output_count(self):
|
def output_count(self):
|
||||||
return self.build_outputs.count()
|
return self.build_outputs.count()
|
||||||
|
|
||||||
|
def has_build_outputs(self):
|
||||||
|
return self.output_count > 0
|
||||||
|
|
||||||
def get_build_outputs(self, **kwargs):
|
def get_build_outputs(self, **kwargs):
|
||||||
"""
|
"""
|
||||||
Return a list of build outputs.
|
Return a list of build outputs.
|
||||||
@@ -705,7 +708,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def deleteBuildOutput(self, output):
|
def delete_output(self, output):
|
||||||
"""
|
"""
|
||||||
Remove a build output from the database:
|
Remove a build output from the database:
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ class BuildOutputSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
build = self.context['build']
|
build = self.context['build']
|
||||||
|
|
||||||
|
# As this serializer can be used in multiple contexts, we need to work out why we are here
|
||||||
|
to_complete = self.context.get('to_complete', False)
|
||||||
|
|
||||||
# The stock item must point to the build
|
# The stock item must point to the build
|
||||||
if output.build != build:
|
if output.build != build:
|
||||||
raise ValidationError(_("Build output does not match the parent build"))
|
raise ValidationError(_("Build output does not match the parent build"))
|
||||||
@@ -153,6 +156,8 @@ class BuildOutputSerializer(serializers.Serializer):
|
|||||||
if not output.is_building:
|
if not output.is_building:
|
||||||
raise ValidationError(_("This build output has already been completed"))
|
raise ValidationError(_("This build output has already been completed"))
|
||||||
|
|
||||||
|
if to_complete:
|
||||||
|
|
||||||
# The build output must have all tracked parts allocated
|
# The build output must have all tracked parts allocated
|
||||||
if not build.isFullyAllocated(output):
|
if not build.isFullyAllocated(output):
|
||||||
raise ValidationError(_("This build output is not fully allocated"))
|
raise ValidationError(_("This build output is not fully allocated"))
|
||||||
@@ -165,6 +170,48 @@ class BuildOutputSerializer(serializers.Serializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BuildOutputDeleteSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
DRF serializer for deleting (cancelling) one or more build outputs
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'outputs',
|
||||||
|
]
|
||||||
|
|
||||||
|
outputs = BuildOutputSerializer(
|
||||||
|
many=True,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
|
||||||
|
data = super().validate(data)
|
||||||
|
|
||||||
|
outputs = data.get('outputs', [])
|
||||||
|
|
||||||
|
if len(outputs) == 0:
|
||||||
|
raise ValidationError(_("A list of build outputs must be provided"))
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
"""
|
||||||
|
'save' the serializer to delete the build outputs
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.validated_data
|
||||||
|
outputs = data.get('outputs', [])
|
||||||
|
|
||||||
|
build = self.context['build']
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for item in outputs:
|
||||||
|
output = item['output']
|
||||||
|
build.delete_output(output)
|
||||||
|
|
||||||
|
|
||||||
class BuildOutputCompleteSerializer(serializers.Serializer):
|
class BuildOutputCompleteSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
DRF serializer for completing one or more build outputs
|
DRF serializer for completing one or more build outputs
|
||||||
@@ -284,6 +331,9 @@ class BuildCompleteSerializer(serializers.Serializer):
|
|||||||
if build.incomplete_count > 0:
|
if build.incomplete_count > 0:
|
||||||
raise ValidationError(_("Build order has incomplete outputs"))
|
raise ValidationError(_("Build order has incomplete outputs"))
|
||||||
|
|
||||||
|
if not build.has_build_outputs():
|
||||||
|
raise ValidationError(_("No build outputs have been created for this build order"))
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from allauth.account.models import EmailAddress
|
|||||||
import build.models
|
import build.models
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
|
from InvenTree.ready import isImportingData
|
||||||
|
|
||||||
import part.models as part_models
|
import part.models as part_models
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +26,10 @@ def check_build_stock(build: build.models.Build):
|
|||||||
and send an email out to any subscribed users if stock is low.
|
and send an email out to any subscribed users if stock is low.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Do not notify if we are importing data
|
||||||
|
if isImportingData():
|
||||||
|
return
|
||||||
|
|
||||||
# Iterate through each of the parts required for this build
|
# Iterate through each of the parts required for this build
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class='info-messages'>
|
<div class='info-messages'>
|
||||||
|
{% if not build.has_build_outputs %}
|
||||||
|
<div class='alert alert-block alert-danger'>
|
||||||
|
{% trans "No build outputs have been created for this build order" %}<br>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if build.sales_order %}
|
{% if build.sales_order %}
|
||||||
<div class='alert alert-block alert-info'>
|
<div class='alert alert-block alert-info'>
|
||||||
{% object_link 'so-detail' build.sales_order.id build.sales_order as link %}
|
{% object_link 'so-detail' build.sales_order.id build.sales_order as link %}
|
||||||
|
|||||||
@@ -243,15 +243,19 @@
|
|||||||
|
|
||||||
<!-- Build output actions -->
|
<!-- Build output actions -->
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button id='output-options' class='btn btn-primary dropdown-toiggle' type='button' data-bs-toggle='dropdown' title='{% trans "Output Actions" %}'>
|
<button id='output-options' class='btn btn-primary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Output Actions" %}'>
|
||||||
<span class='fas fa-tools'></span> <span class='caret'></span>
|
<span class='fas fa-tools'></span> <span class='caret'></span>
|
||||||
</button>
|
</button>
|
||||||
<ul class='dropdown-menu'>
|
<ul class='dropdown-menu'>
|
||||||
<li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected items" %}'>
|
<li><a class='dropdown-item' href='#' id='multi-output-complete' title='{% trans "Complete selected build outputs" %}'>
|
||||||
<span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}
|
<span class='fas fa-check-circle icon-green'></span> {% trans "Complete outputs" %}
|
||||||
</a></li>
|
</a></li>
|
||||||
|
<li><a class='dropdown-item' href='#' id='multi-output-delete' title='{% trans "Delete selected build outputs" %}'>
|
||||||
|
<span class='fas fa-trash-alt icon-red'></span> {% trans "Delete outputs" %}
|
||||||
|
</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
{% include "filter_list.html" with id='incompletebuilditems' %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -371,6 +375,7 @@ inventreeGet(
|
|||||||
[
|
[
|
||||||
'#output-options',
|
'#output-options',
|
||||||
'#multi-output-complete',
|
'#multi-output-complete',
|
||||||
|
'#multi-output-delete',
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -392,6 +397,24 @@ inventreeGet(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#multi-output-delete').click(function() {
|
||||||
|
var outputs = $('#build-output-table').bootstrapTable('getSelections');
|
||||||
|
|
||||||
|
deleteBuildOutputs(
|
||||||
|
build_info.pk,
|
||||||
|
outputs,
|
||||||
|
{
|
||||||
|
success: function() {
|
||||||
|
// Reload the "in progress" table
|
||||||
|
$('#build-output-table').bootstrapTable('refresh');
|
||||||
|
|
||||||
|
// Reload the "completed" table
|
||||||
|
$('#build-stock-table').bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if build.active and build.has_untracked_bom_items %}
|
{% if build.active and build.has_untracked_bom_items %}
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ class BuildOutputCompleteTest(BuildAPITest):
|
|||||||
self.assertTrue('accept_unallocated' in response.data)
|
self.assertTrue('accept_unallocated' in response.data)
|
||||||
|
|
||||||
# Accept unallocated stock
|
# Accept unallocated stock
|
||||||
response = self.post(
|
self.post(
|
||||||
finish_url,
|
finish_url,
|
||||||
{
|
{
|
||||||
'accept_unallocated': True,
|
'accept_unallocated': True,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ build_detail_urls = [
|
|||||||
url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'),
|
url(r'^cancel/', views.BuildCancel.as_view(), name='build-cancel'),
|
||||||
url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
|
url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'),
|
||||||
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
|
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
|
||||||
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
|
|
||||||
|
|
||||||
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
|
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from django.forms import HiddenInput
|
|||||||
|
|
||||||
from .models import Build
|
from .models import Build
|
||||||
from . import forms
|
from . import forms
|
||||||
from stock.models import StockItem
|
|
||||||
|
|
||||||
from InvenTree.views import AjaxUpdateView, AjaxDeleteView
|
from InvenTree.views import AjaxUpdateView, AjaxDeleteView
|
||||||
from InvenTree.views import InvenTreeRoleMixin
|
from InvenTree.views import InvenTreeRoleMixin
|
||||||
@@ -95,17 +94,24 @@ class BuildOutputCreate(AjaxUpdateView):
|
|||||||
quantity = form.cleaned_data.get('output_quantity', None)
|
quantity = form.cleaned_data.get('output_quantity', None)
|
||||||
serials = form.cleaned_data.get('serial_numbers', None)
|
serials = form.cleaned_data.get('serial_numbers', None)
|
||||||
|
|
||||||
if quantity:
|
if quantity is not None:
|
||||||
build = self.get_object()
|
build = self.get_object()
|
||||||
|
|
||||||
# Check that requested output don't exceed build remaining quantity
|
# Check that requested output don't exceed build remaining quantity
|
||||||
maximum_output = int(build.remaining - build.incomplete_count)
|
maximum_output = int(build.remaining - build.incomplete_count)
|
||||||
|
|
||||||
if quantity > maximum_output:
|
if quantity > maximum_output:
|
||||||
form.add_error(
|
form.add_error(
|
||||||
'output_quantity',
|
'output_quantity',
|
||||||
_('Maximum output quantity is ') + str(maximum_output),
|
_('Maximum output quantity is ') + str(maximum_output),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif quantity <= 0:
|
||||||
|
form.add_error(
|
||||||
|
'output_quantity',
|
||||||
|
_('Output quantity must be greater than zero'),
|
||||||
|
)
|
||||||
|
|
||||||
# Check that the serial numbers are valid
|
# Check that the serial numbers are valid
|
||||||
if serials:
|
if serials:
|
||||||
try:
|
try:
|
||||||
@@ -185,67 +191,6 @@ class BuildOutputCreate(AjaxUpdateView):
|
|||||||
return form
|
return form
|
||||||
|
|
||||||
|
|
||||||
class BuildOutputDelete(AjaxUpdateView):
|
|
||||||
"""
|
|
||||||
Delete a build output (StockItem) for a given build.
|
|
||||||
|
|
||||||
Form is a simple confirmation dialog
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = Build
|
|
||||||
form_class = forms.BuildOutputDeleteForm
|
|
||||||
ajax_form_title = _('Delete Build Output')
|
|
||||||
|
|
||||||
role_required = 'build.delete'
|
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
|
|
||||||
initials = super().get_initial()
|
|
||||||
|
|
||||||
output = self.get_param('output')
|
|
||||||
|
|
||||||
initials['output_id'] = output
|
|
||||||
|
|
||||||
return initials
|
|
||||||
|
|
||||||
def validate(self, build, form, **kwargs):
|
|
||||||
|
|
||||||
data = form.cleaned_data
|
|
||||||
|
|
||||||
confirm = data.get('confirm', False)
|
|
||||||
|
|
||||||
if not confirm:
|
|
||||||
form.add_error('confirm', _('Confirm unallocation of build stock'))
|
|
||||||
form.add_error(None, _('Check the confirmation box'))
|
|
||||||
|
|
||||||
output_id = data.get('output_id', None)
|
|
||||||
output = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
output = StockItem.objects.get(pk=output_id)
|
|
||||||
except (ValueError, StockItem.DoesNotExist):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if output:
|
|
||||||
if not output.build == build:
|
|
||||||
form.add_error(None, _('Build output does not match build'))
|
|
||||||
else:
|
|
||||||
form.add_error(None, _('Build output must be specified'))
|
|
||||||
|
|
||||||
def save(self, build, form, **kwargs):
|
|
||||||
|
|
||||||
output_id = form.cleaned_data.get('output_id')
|
|
||||||
|
|
||||||
output = StockItem.objects.get(pk=output_id)
|
|
||||||
|
|
||||||
build.deleteBuildOutput(output)
|
|
||||||
|
|
||||||
def get_data(self):
|
|
||||||
return {
|
|
||||||
'danger': _('Build output deleted'),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class BuildDetail(InvenTreeRoleMixin, DetailView):
|
class BuildDetail(InvenTreeRoleMixin, DetailView):
|
||||||
"""
|
"""
|
||||||
Detail view of a single Build object.
|
Detail view of a single Build object.
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ class WebhookView(CsrfExemptMixin, APIView):
|
|||||||
message,
|
message,
|
||||||
)
|
)
|
||||||
|
|
||||||
# return results
|
|
||||||
data = self.webhook.get_return(payload, headers, request)
|
data = self.webhook.get_return(payload, headers, request)
|
||||||
return HttpResponse(data)
|
return HttpResponse(data)
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import os
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
# from company.models import ManufacturerPart, SupplierPart
|
|
||||||
|
|
||||||
|
|
||||||
class FileManager:
|
class FileManager:
|
||||||
""" Class for managing an uploaded file """
|
""" Class for managing an uploaded file """
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
setting.value = str(value)
|
setting.value = str(value)
|
||||||
setting.save()
|
setting.save()
|
||||||
|
|
||||||
key = models.CharField(max_length=50, blank=False, unique=False, help_text=_('Settings key (must be unique - case insensitive'))
|
key = models.CharField(max_length=50, blank=False, unique=False, help_text=_('Settings key (must be unique - case insensitive)'))
|
||||||
|
|
||||||
value = models.CharField(max_length=200, blank=True, unique=False, help_text=_('Settings value'))
|
value = models.CharField(max_length=200, blank=True, unique=False, help_text=_('Settings value'))
|
||||||
|
|
||||||
@@ -781,6 +781,18 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# 2022-02-03
|
||||||
|
# This setting exists as an interim solution for extremely slow part page load times when the part has a complex BOM
|
||||||
|
# In an upcoming release, pricing history (and BOM pricing) will be cached,
|
||||||
|
# rather than having to be re-calculated every time the page is loaded!
|
||||||
|
# For now, we will simply hide part pricing by default
|
||||||
|
'PART_SHOW_PRICE_HISTORY': {
|
||||||
|
'name': _('Show Price History'),
|
||||||
|
'description': _('Display historical pricing for Part'),
|
||||||
|
'default': False,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
|
||||||
'PART_SHOW_RELATED': {
|
'PART_SHOW_RELATED': {
|
||||||
'name': _('Show related parts'),
|
'name': _('Show related parts'),
|
||||||
'description': _('Display related parts for a part'),
|
'description': _('Display related parts for a part'),
|
||||||
@@ -1480,11 +1492,9 @@ class WebhookEndpoint(models.Model):
|
|||||||
|
|
||||||
def process_webhook(self):
|
def process_webhook(self):
|
||||||
if self.token:
|
if self.token:
|
||||||
self.token = self.token
|
|
||||||
self.verify = VerificationMethod.TOKEN
|
self.verify = VerificationMethod.TOKEN
|
||||||
# TODO make a object-setting
|
# TODO make a object-setting
|
||||||
if self.secret:
|
if self.secret:
|
||||||
self.secret = self.secret
|
|
||||||
self.verify = VerificationMethod.HMAC
|
self.verify = VerificationMethod.HMAC
|
||||||
# TODO make a object-setting
|
# TODO make a object-setting
|
||||||
return True
|
return True
|
||||||
@@ -1494,6 +1504,7 @@ class WebhookEndpoint(models.Model):
|
|||||||
|
|
||||||
# no token
|
# no token
|
||||||
if self.verify == VerificationMethod.NONE:
|
if self.verify == VerificationMethod.NONE:
|
||||||
|
# do nothing as no method was chosen
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# static token
|
# static token
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from django.template.loader import render_to_string
|
|||||||
from allauth.account.models import EmailAddress
|
from allauth.account.models import EmailAddress
|
||||||
|
|
||||||
from InvenTree.helpers import inheritors
|
from InvenTree.helpers import inheritors
|
||||||
|
from InvenTree.ready import isImportingData
|
||||||
from common.models import NotificationEntry, NotificationMessage
|
from common.models import NotificationEntry, NotificationMessage
|
||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
|
|
||||||
@@ -144,6 +145,10 @@ def trigger_notifaction(obj, category=None, obj_ref='pk', targets=None, target_f
|
|||||||
"""
|
"""
|
||||||
Send out an notification
|
Send out an notification
|
||||||
"""
|
"""
|
||||||
|
# check if data is importet currently
|
||||||
|
if isImportingData():
|
||||||
|
return
|
||||||
|
|
||||||
# Resolve objekt reference
|
# Resolve objekt reference
|
||||||
obj_ref_value = getattr(obj, obj_ref)
|
obj_ref_value = getattr(obj, obj_ref)
|
||||||
# Try with some defaults
|
# Try with some defaults
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from django.contrib.auth import get_user_model
|
|||||||
from .models import InvenTreeSetting, WebhookEndpoint, WebhookMessage, NotificationEntry
|
from .models import InvenTreeSetting, WebhookEndpoint, WebhookMessage, NotificationEntry
|
||||||
from .api import WebhookView
|
from .api import WebhookView
|
||||||
|
|
||||||
|
CONTENT_TYPE_JSON = 'application/json'
|
||||||
|
|
||||||
|
|
||||||
class SettingsTest(TestCase):
|
class SettingsTest(TestCase):
|
||||||
"""
|
"""
|
||||||
@@ -105,7 +107,7 @@ class WebhookMessageTests(TestCase):
|
|||||||
def test_missing_token(self):
|
def test_missing_token(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.url,
|
self.url,
|
||||||
content_type='application/json',
|
content_type=CONTENT_TYPE_JSON,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||||
@@ -116,7 +118,7 @@ class WebhookMessageTests(TestCase):
|
|||||||
def test_bad_token(self):
|
def test_bad_token(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.url,
|
self.url,
|
||||||
content_type='application/json',
|
content_type=CONTENT_TYPE_JSON,
|
||||||
**{'HTTP_TOKEN': '1234567fghj'},
|
**{'HTTP_TOKEN': '1234567fghj'},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -126,7 +128,7 @@ class WebhookMessageTests(TestCase):
|
|||||||
def test_bad_url(self):
|
def test_bad_url(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/api/webhook/1234/',
|
'/api/webhook/1234/',
|
||||||
content_type='application/json',
|
content_type=CONTENT_TYPE_JSON,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||||
@@ -135,7 +137,7 @@ class WebhookMessageTests(TestCase):
|
|||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.url,
|
self.url,
|
||||||
data="{'this': 123}",
|
data="{'this': 123}",
|
||||||
content_type='application/json',
|
content_type=CONTENT_TYPE_JSON,
|
||||||
**{'HTTP_TOKEN': str(self.endpoint_def.token)},
|
**{'HTTP_TOKEN': str(self.endpoint_def.token)},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -152,7 +154,7 @@ class WebhookMessageTests(TestCase):
|
|||||||
# check
|
# check
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.url,
|
self.url,
|
||||||
content_type='application/json',
|
content_type=CONTENT_TYPE_JSON,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == HTTPStatus.OK
|
assert response.status_code == HTTPStatus.OK
|
||||||
@@ -167,7 +169,7 @@ class WebhookMessageTests(TestCase):
|
|||||||
# check
|
# check
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.url,
|
self.url,
|
||||||
content_type='application/json',
|
content_type=CONTENT_TYPE_JSON,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||||
@@ -182,7 +184,7 @@ class WebhookMessageTests(TestCase):
|
|||||||
# check
|
# check
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.url,
|
self.url,
|
||||||
content_type='application/json',
|
content_type=CONTENT_TYPE_JSON,
|
||||||
**{'HTTP_TOKEN': str('68MXtc/OiXdA5e2Nq9hATEVrZFpLb3Zb0oau7n8s31I=')},
|
**{'HTTP_TOKEN': str('68MXtc/OiXdA5e2Nq9hATEVrZFpLb3Zb0oau7n8s31I=')},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -193,7 +195,7 @@ class WebhookMessageTests(TestCase):
|
|||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.url,
|
self.url,
|
||||||
data={"this": "is a message"},
|
data={"this": "is a message"},
|
||||||
content_type='application/json',
|
content_type=CONTENT_TYPE_JSON,
|
||||||
**{'HTTP_TOKEN': str(self.endpoint_def.token)},
|
**{'HTTP_TOKEN': str(self.endpoint_def.token)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1343
-1229
File diff suppressed because it is too large
Load Diff
+1270
-1156
File diff suppressed because it is too large
Load Diff
+1270
-1156
File diff suppressed because it is too large
Load Diff
+1724
-1610
File diff suppressed because it is too large
Load Diff
+1364
-1250
File diff suppressed because it is too large
Load Diff
+1270
-1156
File diff suppressed because it is too large
Load Diff
+1271
-1157
File diff suppressed because it is too large
Load Diff
+1270
-1156
File diff suppressed because it is too large
Load Diff
+1393
-1279
File diff suppressed because it is too large
Load Diff
+1271
-1157
File diff suppressed because it is too large
Load Diff
+1357
-1243
File diff suppressed because it is too large
Load Diff
+1270
-1156
File diff suppressed because it is too large
Load Diff
+1270
-1156
File diff suppressed because it is too large
Load Diff
+1272
-1158
File diff suppressed because it is too large
Load Diff
+1270
-1156
File diff suppressed because it is too large
Load Diff
+1270
-1156
File diff suppressed because it is too large
Load Diff
+1270
-1156
File diff suppressed because it is too large
Load Diff
+1270
-1156
File diff suppressed because it is too large
Load Diff
+1274
-1160
File diff suppressed because it is too large
Load Diff
@@ -822,6 +822,7 @@ class SOAllocationList(generics.ListAPIView):
|
|||||||
kwargs['item_detail'] = str2bool(params.get('item_detail', False))
|
kwargs['item_detail'] = str2bool(params.get('item_detail', False))
|
||||||
kwargs['order_detail'] = str2bool(params.get('order_detail', False))
|
kwargs['order_detail'] = str2bool(params.get('order_detail', False))
|
||||||
kwargs['location_detail'] = str2bool(params.get('location_detail', False))
|
kwargs['location_detail'] = str2bool(params.get('location_detail', False))
|
||||||
|
kwargs['customer_detail'] = str2bool(params.get('customer_detail', False))
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -846,6 +847,12 @@ class SOAllocationList(generics.ListAPIView):
|
|||||||
if order is not None:
|
if order is not None:
|
||||||
queryset = queryset.filter(line__order=order)
|
queryset = queryset.filter(line__order=order)
|
||||||
|
|
||||||
|
# Filter by "stock item"
|
||||||
|
item = params.get('item', params.get('stock_item', None))
|
||||||
|
|
||||||
|
if item is not None:
|
||||||
|
queryset = queryset.filter(item=item)
|
||||||
|
|
||||||
# Filter by "outstanding" order status
|
# Filter by "outstanding" order status
|
||||||
outstanding = params.get('outstanding', None)
|
outstanding = params.get('outstanding', None)
|
||||||
|
|
||||||
@@ -865,7 +872,6 @@ class SOAllocationList(generics.ListAPIView):
|
|||||||
|
|
||||||
# Default filterable fields
|
# Default filterable fields
|
||||||
filter_fields = [
|
filter_fields = [
|
||||||
'item',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -92,5 +92,4 @@ class OrderMatchItemForm(MatchItemForm):
|
|||||||
default_amount=clean_decimal(row.get('purchase_price', '')),
|
default_amount=clean_decimal(row.get('purchase_price', '')),
|
||||||
)
|
)
|
||||||
|
|
||||||
# return default
|
|
||||||
return super().get_special_field(col_guess, row, file_manager)
|
return super().get_special_field(col_guess, row, file_manager)
|
||||||
|
|||||||
+18
-10
@@ -27,6 +27,7 @@ from stock import models as stock_models
|
|||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
from plugin.events import trigger_event
|
from plugin.events import trigger_event
|
||||||
|
|
||||||
|
import InvenTree.helpers
|
||||||
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
|
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
|
||||||
from InvenTree.helpers import decimal2string, increment, getSetting
|
from InvenTree.helpers import decimal2string, increment, getSetting
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus, StockHistoryCode
|
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus, StockHistoryCode
|
||||||
@@ -414,16 +415,12 @@ class PurchaseOrder(Order):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not (quantity % 1 == 0):
|
|
||||||
raise ValidationError({
|
|
||||||
"quantity": _("Quantity must be an integer")
|
|
||||||
})
|
|
||||||
if quantity < 0:
|
if quantity < 0:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
"quantity": _("Quantity must be a positive number")
|
"quantity": _("Quantity must be a positive number")
|
||||||
})
|
})
|
||||||
quantity = int(quantity)
|
quantity = InvenTree.helpers.clean_decimal(quantity)
|
||||||
except (ValueError, TypeError):
|
except TypeError:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
"quantity": _("Invalid quantity provided")
|
"quantity": _("Invalid quantity provided")
|
||||||
})
|
})
|
||||||
@@ -825,15 +822,26 @@ class PurchaseOrderLineItem(OrderLineItem):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_api_url():
|
|
||||||
return reverse('api-po-line-list')
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = (
|
unique_together = (
|
||||||
('order', 'part', 'quantity', 'purchase_price')
|
('order', 'part', 'quantity', 'purchase_price')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_api_url():
|
||||||
|
return reverse('api-po-line-list')
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
if self.order.supplier and self.part:
|
||||||
|
# Supplier part *must* point to the same supplier!
|
||||||
|
if self.part.supplier != self.order.supplier:
|
||||||
|
raise ValidationError({
|
||||||
|
'part': _('Supplier part must match supplier')
|
||||||
|
})
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "{n} x {part} from {supplier} (for {po})".format(
|
return "{n} x {part} from {supplier} (for {po})".format(
|
||||||
n=decimal2string(self.quantity),
|
n=decimal2string(self.quantity),
|
||||||
|
|||||||
@@ -495,6 +495,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
|||||||
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
|
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
|
||||||
item_detail = stock.serializers.StockItemSerializer(source='item', many=False, read_only=True)
|
item_detail = stock.serializers.StockItemSerializer(source='item', many=False, read_only=True)
|
||||||
location_detail = stock.serializers.LocationSerializer(source='item.location', many=False, read_only=True)
|
location_detail = stock.serializers.LocationSerializer(source='item.location', many=False, read_only=True)
|
||||||
|
customer_detail = CompanyBriefSerializer(source='line.order.customer', many=False, read_only=True)
|
||||||
|
|
||||||
shipment_date = serializers.DateField(source='shipment.shipment_date', read_only=True)
|
shipment_date = serializers.DateField(source='shipment.shipment_date', read_only=True)
|
||||||
|
|
||||||
@@ -504,6 +505,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
|||||||
part_detail = kwargs.pop('part_detail', True)
|
part_detail = kwargs.pop('part_detail', True)
|
||||||
item_detail = kwargs.pop('item_detail', False)
|
item_detail = kwargs.pop('item_detail', False)
|
||||||
location_detail = kwargs.pop('location_detail', False)
|
location_detail = kwargs.pop('location_detail', False)
|
||||||
|
customer_detail = kwargs.pop('customer_detail', False)
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
@@ -519,12 +521,16 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
|||||||
if not location_detail:
|
if not location_detail:
|
||||||
self.fields.pop('location_detail')
|
self.fields.pop('location_detail')
|
||||||
|
|
||||||
|
if not customer_detail:
|
||||||
|
self.fields.pop('customer_detail')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = order.models.SalesOrderAllocation
|
model = order.models.SalesOrderAllocation
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
'line',
|
'line',
|
||||||
|
'customer_detail',
|
||||||
'serial',
|
'serial',
|
||||||
'quantity',
|
'quantity',
|
||||||
'location',
|
'location',
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %}
|
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||||
<button type='button' class='btn btn-outline-secondary' id='place-order' title='{% trans "Place order" %}'>
|
<button type='button' class='btn btn-outline-secondary' id='place-order' title='{% trans "Place order" %}'>
|
||||||
<span class='fas fa-shopping-cart icon-blue'></span>
|
<span class='fas fa-shopping-cart icon-blue'></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -178,7 +178,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
|
|
||||||
{% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %}
|
{% if order.status == PurchaseOrderStatus.PENDING %}
|
||||||
$("#place-order").click(function() {
|
$("#place-order").click(function() {
|
||||||
launchModalForm("{% url 'po-issue' order.id %}",
|
launchModalForm("{% url 'po-issue' order.id %}",
|
||||||
{
|
{
|
||||||
|
|||||||
+41
-3
@@ -446,10 +446,10 @@ class PartSerialNumberDetail(generics.RetrieveAPIView):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if latest is not None:
|
if latest is not None:
|
||||||
next = increment(latest)
|
next_serial = increment(latest)
|
||||||
|
|
||||||
if next != increment:
|
if next_serial != increment:
|
||||||
data['next'] = next
|
data['next'] = next_serial
|
||||||
|
|
||||||
return Response(data)
|
return Response(data)
|
||||||
|
|
||||||
@@ -1533,6 +1533,40 @@ class BomList(generics.ListCreateAPIView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BomExtract(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for extracting BOM data from a BOM file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = Part.objects.none()
|
||||||
|
serializer_class = part_serializers.BomExtractSerializer
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Custom create function to return the extracted data
|
||||||
|
"""
|
||||||
|
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
self.perform_create(serializer)
|
||||||
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
|
||||||
|
data = serializer.extract_data()
|
||||||
|
|
||||||
|
return Response(data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
class BomUpload(generics.CreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for uploading a complete Bill of Materials.
|
||||||
|
|
||||||
|
It is assumed that the BOM has been extracted from a file using the BomExtract endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = Part.objects.all()
|
||||||
|
serializer_class = part_serializers.BomUploadSerializer
|
||||||
|
|
||||||
|
|
||||||
class BomDetail(generics.RetrieveUpdateDestroyAPIView):
|
class BomDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API endpoint for detail view of a single BomItem object """
|
""" API endpoint for detail view of a single BomItem object """
|
||||||
|
|
||||||
@@ -1685,6 +1719,10 @@ bom_api_urls = [
|
|||||||
url(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'),
|
url(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
url(r'^extract/', BomExtract.as_view(), name='api-bom-extract'),
|
||||||
|
|
||||||
|
url(r'^upload/', BomUpload.as_view(), name='api-bom-upload'),
|
||||||
|
|
||||||
# Catch-all
|
# Catch-all
|
||||||
url(r'^.*$', BomList.as_view(), name='api-bom-list'),
|
url(r'^.*$', BomList.as_view(), name='api-bom-list'),
|
||||||
]
|
]
|
||||||
|
|||||||
+22
-4
@@ -123,16 +123,22 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
|||||||
|
|
||||||
stock_headers = [
|
stock_headers = [
|
||||||
_('Default Location'),
|
_('Default Location'),
|
||||||
|
_('Total Stock'),
|
||||||
_('Available Stock'),
|
_('Available Stock'),
|
||||||
|
_('On Order'),
|
||||||
]
|
]
|
||||||
|
|
||||||
stock_cols = {}
|
stock_cols = {}
|
||||||
|
|
||||||
for b_idx, bom_item in enumerate(bom_items):
|
for b_idx, bom_item in enumerate(bom_items):
|
||||||
|
|
||||||
stock_data = []
|
stock_data = []
|
||||||
|
|
||||||
|
sub_part = bom_item.sub_part
|
||||||
|
|
||||||
# Get part default location
|
# Get part default location
|
||||||
try:
|
try:
|
||||||
loc = bom_item.sub_part.get_default_location()
|
loc = sub_part.get_default_location()
|
||||||
|
|
||||||
if loc is not None:
|
if loc is not None:
|
||||||
stock_data.append(str(loc.name))
|
stock_data.append(str(loc.name))
|
||||||
@@ -141,8 +147,20 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
stock_data.append('')
|
stock_data.append('')
|
||||||
|
|
||||||
# Get part current stock
|
# Total "in stock" quantity for this part
|
||||||
stock_data.append(str(normalize(bom_item.sub_part.available_stock)))
|
stock_data.append(
|
||||||
|
str(normalize(sub_part.total_stock))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Total "available stock" quantity for this part
|
||||||
|
stock_data.append(
|
||||||
|
str(normalize(sub_part.available_stock))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Total "on order" quantity for this part
|
||||||
|
stock_data.append(
|
||||||
|
str(normalize(sub_part.on_order))
|
||||||
|
)
|
||||||
|
|
||||||
for s_idx, header in enumerate(stock_headers):
|
for s_idx, header in enumerate(stock_headers):
|
||||||
try:
|
try:
|
||||||
@@ -205,7 +223,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
|||||||
|
|
||||||
supplier_parts_used.add(sp_part)
|
supplier_parts_used.add(sp_part)
|
||||||
|
|
||||||
if sp_part.supplier and sp_part.supplier:
|
if sp_part.supplier:
|
||||||
supplier_name = sp_part.supplier.name
|
supplier_name = sp_part.supplier.name
|
||||||
else:
|
else:
|
||||||
supplier_name = ''
|
supplier_name = ''
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ class BomMatchItemForm(MatchItemForm):
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
# return default
|
|
||||||
return super().get_special_field(col_guess, row, file_manager)
|
return super().get_special_field(col_guess, row, file_manager)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+13
-15
@@ -1530,15 +1530,15 @@ class Part(MPTTModel):
|
|||||||
returns a string representation of a hash object which can be compared with a stored value
|
returns a string representation of a hash object which can be compared with a stored value
|
||||||
"""
|
"""
|
||||||
|
|
||||||
hash = hashlib.md5(str(self.id).encode())
|
result_hash = hashlib.md5(str(self.id).encode())
|
||||||
|
|
||||||
# List *all* BOM items (including inherited ones!)
|
# List *all* BOM items (including inherited ones!)
|
||||||
bom_items = self.get_bom_items().all().prefetch_related('sub_part')
|
bom_items = self.get_bom_items().all().prefetch_related('sub_part')
|
||||||
|
|
||||||
for item in bom_items:
|
for item in bom_items:
|
||||||
hash.update(str(item.get_item_hash()).encode())
|
result_hash.update(str(item.get_item_hash()).encode())
|
||||||
|
|
||||||
return str(hash.digest())
|
return str(result_hash.digest())
|
||||||
|
|
||||||
def is_bom_valid(self):
|
def is_bom_valid(self):
|
||||||
""" Check if the BOM is 'valid' - if the calculated checksum matches the stored value
|
""" Check if the BOM is 'valid' - if the calculated checksum matches the stored value
|
||||||
@@ -2188,9 +2188,7 @@ def after_save_part(sender, instance: Part, created, **kwargs):
|
|||||||
Function to be executed after a Part is saved
|
Function to be executed after a Part is saved
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if created:
|
if not created:
|
||||||
pass
|
|
||||||
else:
|
|
||||||
# Check part stock only if we are *updating* the part (not creating it)
|
# Check part stock only if we are *updating* the part (not creating it)
|
||||||
|
|
||||||
# Run this check in the background
|
# Run this check in the background
|
||||||
@@ -2678,18 +2676,18 @@ class BomItem(models.Model):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Seed the hash with the ID of this BOM item
|
# Seed the hash with the ID of this BOM item
|
||||||
hash = hashlib.md5(str(self.id).encode())
|
result_hash = hashlib.md5(str(self.id).encode())
|
||||||
|
|
||||||
# Update the hash based on line information
|
# Update the hash based on line information
|
||||||
hash.update(str(self.sub_part.id).encode())
|
result_hash.update(str(self.sub_part.id).encode())
|
||||||
hash.update(str(self.sub_part.full_name).encode())
|
result_hash.update(str(self.sub_part.full_name).encode())
|
||||||
hash.update(str(self.quantity).encode())
|
result_hash.update(str(self.quantity).encode())
|
||||||
hash.update(str(self.note).encode())
|
result_hash.update(str(self.note).encode())
|
||||||
hash.update(str(self.reference).encode())
|
result_hash.update(str(self.reference).encode())
|
||||||
hash.update(str(self.optional).encode())
|
result_hash.update(str(self.optional).encode())
|
||||||
hash.update(str(self.inherited).encode())
|
result_hash.update(str(self.inherited).encode())
|
||||||
|
|
||||||
return str(hash.digest())
|
return str(result_hash.digest())
|
||||||
|
|
||||||
def validate_hash(self, valid=True):
|
def validate_hash(self, valid=True):
|
||||||
""" Mark this item as 'valid' (store the checksum hash).
|
""" Mark this item as 'valid' (store the checksum hash).
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ JSON serializers for Part app
|
|||||||
|
|
||||||
import imghdr
|
import imghdr
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
import os
|
||||||
|
import tablib
|
||||||
|
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.db import models
|
from django.db import models, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
@@ -462,7 +464,13 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
price_range = serializers.CharField(read_only=True)
|
price_range = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
quantity = InvenTreeDecimalField()
|
quantity = InvenTreeDecimalField(required=True)
|
||||||
|
|
||||||
|
def validate_quantity(self, quantity):
|
||||||
|
if quantity <= 0:
|
||||||
|
raise serializers.ValidationError(_("Quantity must be greater than zero"))
|
||||||
|
|
||||||
|
return quantity
|
||||||
|
|
||||||
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True))
|
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True))
|
||||||
|
|
||||||
@@ -699,3 +707,345 @@ class PartCopyBOMSerializer(serializers.Serializer):
|
|||||||
skip_invalid=data.get('skip_invalid', False),
|
skip_invalid=data.get('skip_invalid', False),
|
||||||
include_inherited=data.get('include_inherited', False),
|
include_inherited=data.get('include_inherited', False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BomExtractSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for uploading a file and extracting data from it.
|
||||||
|
|
||||||
|
Note: 2022-02-04 - This needs a *serious* refactor in future, probably
|
||||||
|
|
||||||
|
When parsing the file, the following things happen:
|
||||||
|
|
||||||
|
a) Check file format and validity
|
||||||
|
b) Look for "required" fields
|
||||||
|
c) Look for "part" fields - used to "infer" part
|
||||||
|
|
||||||
|
Once the file itself has been validated, we iterate through each data row:
|
||||||
|
|
||||||
|
- If the "level" column is provided, ignore anything below level 1
|
||||||
|
- Try to "guess" the part based on part_id / part_name / part_ipn
|
||||||
|
- Extract other fields as required
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = [
|
||||||
|
'bom_file',
|
||||||
|
'part',
|
||||||
|
'clear_existing',
|
||||||
|
]
|
||||||
|
|
||||||
|
# These columns must be present
|
||||||
|
REQUIRED_COLUMNS = [
|
||||||
|
'quantity',
|
||||||
|
]
|
||||||
|
|
||||||
|
# We need at least one column to specify a "part"
|
||||||
|
PART_COLUMNS = [
|
||||||
|
'part',
|
||||||
|
'part_id',
|
||||||
|
'part_name',
|
||||||
|
'part_ipn',
|
||||||
|
]
|
||||||
|
|
||||||
|
# These columns are "optional"
|
||||||
|
OPTIONAL_COLUMNS = [
|
||||||
|
'allow_variants',
|
||||||
|
'inherited',
|
||||||
|
'optional',
|
||||||
|
'overage',
|
||||||
|
'note',
|
||||||
|
'reference',
|
||||||
|
]
|
||||||
|
|
||||||
|
def find_matching_column(self, col_name, columns):
|
||||||
|
|
||||||
|
# Direct match
|
||||||
|
if col_name in columns:
|
||||||
|
return col_name
|
||||||
|
|
||||||
|
col_name = col_name.lower().strip()
|
||||||
|
|
||||||
|
for col in columns:
|
||||||
|
if col.lower().strip() == col_name:
|
||||||
|
return col
|
||||||
|
|
||||||
|
# No match
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_matching_data(self, row, col_name, columns):
|
||||||
|
"""
|
||||||
|
Extract data from the row, based on the "expected" column name
|
||||||
|
"""
|
||||||
|
|
||||||
|
col_name = self.find_matching_column(col_name, columns)
|
||||||
|
|
||||||
|
return row.get(col_name, None)
|
||||||
|
|
||||||
|
bom_file = serializers.FileField(
|
||||||
|
label=_("BOM File"),
|
||||||
|
help_text=_("Select Bill of Materials file"),
|
||||||
|
required=True,
|
||||||
|
allow_empty_file=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_bom_file(self, bom_file):
|
||||||
|
"""
|
||||||
|
Perform validation checks on the uploaded BOM file
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.filename = bom_file.name
|
||||||
|
|
||||||
|
name, ext = os.path.splitext(bom_file.name)
|
||||||
|
|
||||||
|
# Remove the leading . from the extension
|
||||||
|
ext = ext[1:]
|
||||||
|
|
||||||
|
accepted_file_types = [
|
||||||
|
'xls', 'xlsx',
|
||||||
|
'csv', 'tsv',
|
||||||
|
'xml',
|
||||||
|
]
|
||||||
|
|
||||||
|
if ext not in accepted_file_types:
|
||||||
|
raise serializers.ValidationError(_("Unsupported file type"))
|
||||||
|
|
||||||
|
# Impose a 50MB limit on uploaded BOM files
|
||||||
|
max_upload_file_size = 50 * 1024 * 1024
|
||||||
|
|
||||||
|
if bom_file.size > max_upload_file_size:
|
||||||
|
raise serializers.ValidationError(_("File is too large"))
|
||||||
|
|
||||||
|
# Read file data into memory (bytes object)
|
||||||
|
try:
|
||||||
|
data = bom_file.read()
|
||||||
|
except Exception as e:
|
||||||
|
raise serializers.ValidationError(str(e))
|
||||||
|
|
||||||
|
if ext in ['csv', 'tsv', 'xml']:
|
||||||
|
try:
|
||||||
|
data = data.decode()
|
||||||
|
except Exception as e:
|
||||||
|
raise serializers.ValidationError(str(e))
|
||||||
|
|
||||||
|
# Convert to a tablib dataset (we expect headers)
|
||||||
|
try:
|
||||||
|
self.dataset = tablib.Dataset().load(data, ext, headers=True)
|
||||||
|
except Exception as e:
|
||||||
|
raise serializers.ValidationError(str(e))
|
||||||
|
|
||||||
|
for header in self.REQUIRED_COLUMNS:
|
||||||
|
|
||||||
|
match = self.find_matching_column(header, self.dataset.headers)
|
||||||
|
|
||||||
|
if match is None:
|
||||||
|
raise serializers.ValidationError(_("Missing required column") + f": '{header}'")
|
||||||
|
|
||||||
|
part_column_matches = {}
|
||||||
|
|
||||||
|
part_match = False
|
||||||
|
|
||||||
|
for col in self.PART_COLUMNS:
|
||||||
|
col_match = self.find_matching_column(col, self.dataset.headers)
|
||||||
|
|
||||||
|
part_column_matches[col] = col_match
|
||||||
|
|
||||||
|
if col_match is not None:
|
||||||
|
part_match = True
|
||||||
|
|
||||||
|
if not part_match:
|
||||||
|
raise serializers.ValidationError(_("No part column found"))
|
||||||
|
|
||||||
|
if len(self.dataset) == 0:
|
||||||
|
raise serializers.ValidationError(_("No data rows found"))
|
||||||
|
|
||||||
|
return bom_file
|
||||||
|
|
||||||
|
def extract_data(self):
|
||||||
|
"""
|
||||||
|
Read individual rows out of the BOM file
|
||||||
|
"""
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
found_parts = set()
|
||||||
|
|
||||||
|
headers = self.dataset.headers
|
||||||
|
|
||||||
|
level_column = self.find_matching_column('level', headers)
|
||||||
|
|
||||||
|
for row in self.dataset.dict:
|
||||||
|
|
||||||
|
row_error = {}
|
||||||
|
|
||||||
|
"""
|
||||||
|
If the "level" column is specified, and this is not a top-level BOM item, ignore the row!
|
||||||
|
"""
|
||||||
|
if level_column is not None:
|
||||||
|
level = row.get('level', None)
|
||||||
|
|
||||||
|
if level is not None:
|
||||||
|
try:
|
||||||
|
level = int(level)
|
||||||
|
if level != 1:
|
||||||
|
continue
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
"""
|
||||||
|
Next, we try to "guess" the part, based on the provided data.
|
||||||
|
|
||||||
|
A) If the part_id is supplied, use that!
|
||||||
|
B) If the part name and/or part_ipn are supplied, maybe we can use those?
|
||||||
|
"""
|
||||||
|
part_id = self.find_matching_data(row, 'part_id', headers)
|
||||||
|
part_name = self.find_matching_data(row, 'part_name', headers)
|
||||||
|
part_ipn = self.find_matching_data(row, 'part_ipn', headers)
|
||||||
|
|
||||||
|
part = None
|
||||||
|
|
||||||
|
if part_id is not None:
|
||||||
|
try:
|
||||||
|
part = Part.objects.get(pk=part_id)
|
||||||
|
except (ValueError, Part.DoesNotExist):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Optionally, specify using field "part"
|
||||||
|
if part is None:
|
||||||
|
pk = self.find_matching_data(row, 'part', headers)
|
||||||
|
|
||||||
|
if pk is not None:
|
||||||
|
try:
|
||||||
|
part = Part.objects.get(pk=pk)
|
||||||
|
except (ValueError, Part.DoesNotExist):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if part is None:
|
||||||
|
|
||||||
|
if part_name or part_ipn:
|
||||||
|
queryset = Part.objects.all()
|
||||||
|
|
||||||
|
if part_name:
|
||||||
|
queryset = queryset.filter(name=part_name)
|
||||||
|
|
||||||
|
if part_ipn:
|
||||||
|
queryset = queryset.filter(IPN=part_ipn)
|
||||||
|
|
||||||
|
# Only if we have a single direct match
|
||||||
|
if queryset.exists():
|
||||||
|
if queryset.count() == 1:
|
||||||
|
part = queryset.first()
|
||||||
|
else:
|
||||||
|
# Multiple matches!
|
||||||
|
row_error['part'] = _('Multiple matching parts found')
|
||||||
|
|
||||||
|
if part is None:
|
||||||
|
if 'part' not in row_error:
|
||||||
|
row_error['part'] = _('No matching part found')
|
||||||
|
else:
|
||||||
|
if part.pk in found_parts:
|
||||||
|
row_error['part'] = _("Duplicate part selected")
|
||||||
|
|
||||||
|
elif not part.component:
|
||||||
|
row_error['part'] = _('Part is not designated as a component')
|
||||||
|
|
||||||
|
found_parts.add(part.pk)
|
||||||
|
|
||||||
|
row['part'] = part.pk if part is not None else None
|
||||||
|
|
||||||
|
"""
|
||||||
|
Read out the 'quantity' column - check that it is valid
|
||||||
|
"""
|
||||||
|
quantity = self.find_matching_data(row, 'quantity', self.dataset.headers)
|
||||||
|
|
||||||
|
if quantity is None:
|
||||||
|
row_error['quantity'] = _('Quantity not provided')
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
quantity = Decimal(quantity)
|
||||||
|
|
||||||
|
if quantity <= 0:
|
||||||
|
row_error['quantity'] = _('Quantity must be greater than zero')
|
||||||
|
except:
|
||||||
|
row_error['quantity'] = _('Invalid quantity')
|
||||||
|
|
||||||
|
# For each "optional" column, ensure the column names are allocated correctly
|
||||||
|
for field_name in self.OPTIONAL_COLUMNS:
|
||||||
|
if field_name not in row:
|
||||||
|
row[field_name] = self.find_matching_data(row, field_name, self.dataset.headers)
|
||||||
|
|
||||||
|
rows.append(row)
|
||||||
|
errors.append(row_error)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'rows': rows,
|
||||||
|
'errors': errors,
|
||||||
|
'headers': headers,
|
||||||
|
'filename': self.filename,
|
||||||
|
}
|
||||||
|
|
||||||
|
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True), required=True)
|
||||||
|
|
||||||
|
clear_existing = serializers.BooleanField(
|
||||||
|
label=_("Clear Existing BOM"),
|
||||||
|
help_text=_("Delete existing BOM data first"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
master_part = data['part']
|
||||||
|
clear_existing = data['clear_existing']
|
||||||
|
|
||||||
|
if clear_existing:
|
||||||
|
|
||||||
|
# Remove all existing BOM items
|
||||||
|
master_part.bom_items.all().delete()
|
||||||
|
|
||||||
|
|
||||||
|
class BomUploadSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for uploading a BOM against a specified part.
|
||||||
|
|
||||||
|
A "BOM" is a set of BomItem objects which are to be validated together as a set
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = BomItemSerializer(many=True, required=True)
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
|
||||||
|
items = data['items']
|
||||||
|
|
||||||
|
if len(items) == 0:
|
||||||
|
raise serializers.ValidationError(_("At least one BOM item is required"))
|
||||||
|
|
||||||
|
data = super().validate(data)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
|
||||||
|
data = self.validated_data
|
||||||
|
|
||||||
|
items = data['items']
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
|
||||||
|
part = item['part']
|
||||||
|
sub_part = item['sub_part']
|
||||||
|
|
||||||
|
# Ignore duplicate BOM items
|
||||||
|
if BomItem.objects.filter(part=part, sub_part=sub_part).exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create a new BomItem object
|
||||||
|
BomItem.objects.create(**item)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise serializers.ValidationError(detail=serializers.as_serializer_error(e))
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
{% extends "part/bom_upload/upload_file.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' style='margin-top:12px;' 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-outline-secondary">{% trans "Previous Step" %}</button>
|
|
||||||
{% endif %}
|
|
||||||
<button type="submit" class="save btn btn-outline-secondary">{% 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-outline-secondary 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'>
|
|
||||||
<strong>{% trans "Duplicate selection" %}</strong>
|
|
||||||
</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-outline-secondary 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 %}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
{% extends "part/bom_upload/upload_file.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-outline-secondary">{% trans "Previous Step" %}</button>
|
|
||||||
{% endif %}
|
|
||||||
<button type="submit" class="save btn btn-outline-secondary">{% trans "Submit Selections" %}</button>
|
|
||||||
{% endblock form_buttons_top %}
|
|
||||||
|
|
||||||
{% block form_content %}
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th></th>
|
|
||||||
<th>{% trans "Row" %}</th>
|
|
||||||
<th>{% trans "Select Part" %}</th>
|
|
||||||
<th>{% trans "Reference" %}</th>
|
|
||||||
<th>{% trans "Quantity" %}</th>
|
|
||||||
{% for col in columns %}
|
|
||||||
{% if col.guess != 'Quantity' %}
|
|
||||||
<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>
|
|
||||||
{% endif %}
|
|
||||||
{% 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-outline-secondary 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>
|
|
||||||
<td>
|
|
||||||
{% for field in form.visible_fields %}
|
|
||||||
{% if field.name == row.item_select %}
|
|
||||||
{{ field }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% if row.errors.part %}
|
|
||||||
<p class='help-inline'>{{ row.errors.part }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% for field in form.visible_fields %}
|
|
||||||
{% if field.name == row.reference %}
|
|
||||||
{{ field|as_crispy_field }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% if row.errors.reference %}
|
|
||||||
<p class='help-inline'>{{ row.errors.reference }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% for field in form.visible_fields %}
|
|
||||||
{% if field.name == row.quantity %}
|
|
||||||
{{ field|as_crispy_field }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% if row.errors.quantity %}
|
|
||||||
<p class='help-inline'>{{ row.errors.quantity }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
{% for item in row.data %}
|
|
||||||
{% if item.column.guess != 'Quantity' %}
|
|
||||||
<td>
|
|
||||||
{% if item.column.guess == 'Overage' %}
|
|
||||||
{% for field in form.visible_fields %}
|
|
||||||
{% if field.name == row.overage %}
|
|
||||||
{{ field|as_crispy_field }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% elif item.column.guess == 'Note' %}
|
|
||||||
{% for field in form.visible_fields %}
|
|
||||||
{% if field.name == row.note %}
|
|
||||||
{{ field|as_crispy_field }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
{{ item.cell }}
|
|
||||||
{% endif %}
|
|
||||||
<input type='hidden' name='row_{{ row.index }}_col_{{ forloop.counter0 }}' value='{{ item.cell }}'/>
|
|
||||||
</td>
|
|
||||||
{% endif %}
|
|
||||||
{% 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
{% extends "part/part_base.html" %}
|
|
||||||
{% load inventree_extras %}
|
|
||||||
{% load i18n %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block sidebar %}
|
|
||||||
{% url "part-detail" part.id as url %}
|
|
||||||
{% trans "Return to BOM" as text %}
|
|
||||||
{% include "sidebar_link.html" with url=url text=text icon="fa-undo" %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block heading %}
|
|
||||||
{% trans "Upload Bill of Materials" %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block actions %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block page_info %}
|
|
||||||
<div class='panel-content'>
|
|
||||||
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
|
|
||||||
{% if description %}- {{ description }}{% endif %}</p>
|
|
||||||
|
|
||||||
<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 %}
|
|
||||||
|
|
||||||
{% block form_alert %}
|
|
||||||
<div class='alert alert-info alert-block'>
|
|
||||||
<strong>{% trans "Requirements for BOM upload" %}:</strong>
|
|
||||||
<ul>
|
|
||||||
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href='#' id='bom-template-download'>{% trans "BOM Upload Template" %}</a></strong></li>
|
|
||||||
<li>{% trans "Each part must already exist in the database" %}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
<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-outline-secondary">{% trans "Previous Step" %}</button>
|
|
||||||
{% endif %}
|
|
||||||
<button type="submit" class="save btn btn-outline-secondary">{% trans "Upload File" %}</button>
|
|
||||||
</form>
|
|
||||||
{% endblock form_buttons_bottom %}
|
|
||||||
</div>
|
|
||||||
{% endblock page_info %}
|
|
||||||
|
|
||||||
{% block js_ready %}
|
|
||||||
{{ block.super }}
|
|
||||||
|
|
||||||
enableSidebar('bom-upload');
|
|
||||||
|
|
||||||
$('#bom-template-download').click(function() {
|
|
||||||
downloadBomTemplate();
|
|
||||||
});
|
|
||||||
|
|
||||||
{% endblock js_ready %}
|
|
||||||
@@ -37,6 +37,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class='panel panel-hidden' id='panel-allocations'>
|
||||||
|
<div class='panel-heading'>
|
||||||
|
<div class='d-flex flex-wrap'>
|
||||||
|
<h4>{% trans "Part Stock Allocations" %}</h4>
|
||||||
|
{% include "spacer.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class='panel-content'>
|
||||||
|
<div id='allocations-button-toolbar'>
|
||||||
|
<div class='btn-group' role='group'>
|
||||||
|
{% include "filter_list.html" with id="allocations" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class='table table-striped table-condensed' data-toolbar='#allocations-button-toolbar' id='part-allocation-table'></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class='panel panel-hidden' id='panel-test-templates'>
|
<div class='panel panel-hidden' id='panel-test-templates'>
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
<div class='d-flex flex-wrap'>
|
<div class='d-flex flex-wrap'>
|
||||||
@@ -109,9 +126,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %}
|
||||||
|
{% if show_price_history %}
|
||||||
<div class='panel panel-hidden' id='panel-pricing'>
|
<div class='panel panel-hidden' id='panel-pricing'>
|
||||||
{% include "part/prices.html" %}
|
{% include "part/prices.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class='panel panel-hidden' id='panel-part-notes'>
|
<div class='panel panel-hidden' id='panel-part-notes'>
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
@@ -631,6 +651,19 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load the "allocations" tab
|
||||||
|
onPanelLoad('allocations', function() {
|
||||||
|
|
||||||
|
loadStockAllocationTable(
|
||||||
|
$("#part-allocation-table"),
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
part: {{ part.pk }},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Load the "related parts" tab
|
// Load the "related parts" tab
|
||||||
onPanelLoad("related-parts", function() {
|
onPanelLoad("related-parts", function() {
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||||
{% settings_value 'PART_SHOW_RELATED' as show_related %}
|
{% settings_value 'PART_SHOW_RELATED' as show_related %}
|
||||||
|
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %}
|
||||||
|
|
||||||
{% trans "Parameters" as text %}
|
{% trans "Parameters" as text %}
|
||||||
{% include "sidebar_item.html" with label="part-parameters" text=text icon="fa-th-list" %}
|
{% include "sidebar_item.html" with label="part-parameters" text=text icon="fa-th-list" %}
|
||||||
@@ -25,8 +26,14 @@
|
|||||||
{% trans "Used In" as text %}
|
{% trans "Used In" as text %}
|
||||||
{% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %}
|
{% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if show_price_history %}
|
||||||
{% trans "Pricing" as text %}
|
{% trans "Pricing" as text %}
|
||||||
{% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %}
|
{% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %}
|
||||||
|
{% endif %}
|
||||||
|
{% if part.salable or part.component %}
|
||||||
|
{% trans "Allocations" as text %}
|
||||||
|
{% include "sidebar_item.html" with label="allocations" text=text icon="fa-bookmark" %}
|
||||||
|
{% endif %}
|
||||||
{% if part.purchaseable and roles.purchase_order.view %}
|
{% if part.purchaseable and roles.purchase_order.view %}
|
||||||
{% trans "Suppliers" as text %}
|
{% trans "Suppliers" as text %}
|
||||||
{% include "sidebar_item.html" with label="suppliers" text=text icon="fa-building" %}
|
{% include "sidebar_item.html" with label="suppliers" text=text icon="fa-building" %}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
{% extends "part/part_base.html" %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block sidebar %}
|
||||||
|
{% url "part-detail" part.id as url %}
|
||||||
|
{% trans "Return to BOM" as text %}
|
||||||
|
{% include "sidebar_link.html" with url=url text=text icon="fa-undo" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block heading %}
|
||||||
|
{% trans "Upload Bill of Materials" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<!--
|
||||||
|
<button type='button' class='btn btn-outline-secondary' id='bom-info'>
|
||||||
|
<span class='fas fa-info-circle' title='{% trans "BOM upload requirements" %}'></span>
|
||||||
|
</button>
|
||||||
|
-->
|
||||||
|
<button type='button' class='btn btn-primary' id='bom-upload'>
|
||||||
|
<span class='fas fa-file-upload'></span> {% trans "Upload BOM File" %}
|
||||||
|
</button>
|
||||||
|
<button type='button' class='btn btn-success' disabled='true' id='bom-submit-icon' style='display: none;'>
|
||||||
|
<span class="fas fa-spin fa-circle-notch"></span>
|
||||||
|
</button>
|
||||||
|
<button type='button' class='btn btn-success' id='bom-submit' style='display: none;'>
|
||||||
|
<span class='fas fa-sign-in-alt' id='bom-submit-icon'></span> {% trans "Submit BOM Data" %}
|
||||||
|
</button>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_info %}
|
||||||
|
<div class='panel-content'>
|
||||||
|
|
||||||
|
<div class='alert alert-info alert-block'>
|
||||||
|
<strong>{% trans "Requirements for BOM upload" %}:</strong>
|
||||||
|
<ul>
|
||||||
|
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href='#' id='bom-template-download'>{% trans "BOM Upload Template" %}</a></strong></li>
|
||||||
|
<li>{% trans "Each part must already exist in the database" %}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id='non-field-errors'>
|
||||||
|
<!-- Upload error messages go here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- This table is filled out after BOM file is uploaded and processed -->
|
||||||
|
<table class='table table-condensed' id='bom-import-table'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style='max-width: 500px;'>{% trans "Part" %}</th>
|
||||||
|
<th>{% trans "Quantity" %}</th>
|
||||||
|
<th>{% trans "Reference" %}</th>
|
||||||
|
<th>{% trans "Overage" %}</th>
|
||||||
|
<th>{% trans "Allow Variants" %}</th>
|
||||||
|
<th>{% trans "Inherited" %}</th>
|
||||||
|
<th>{% trans "Optional" %}</th>
|
||||||
|
<th>{% trans "Note" %}</th>
|
||||||
|
<th><!-- Buttons Column --></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock page_info %}
|
||||||
|
|
||||||
|
{% block js_ready %}
|
||||||
|
{{ block.super }}
|
||||||
|
|
||||||
|
enableSidebar('bom-upload');
|
||||||
|
|
||||||
|
$('#bom-template-download').click(function() {
|
||||||
|
downloadBomTemplate();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#bom-upload').click(function() {
|
||||||
|
|
||||||
|
constructForm('{% url "api-bom-extract" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
fields: {
|
||||||
|
bom_file: {},
|
||||||
|
part: {
|
||||||
|
value: {{ part.pk }},
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
clear_existing: {},
|
||||||
|
},
|
||||||
|
title: '{% trans "Upload BOM File" %}',
|
||||||
|
onSuccess: function(response) {
|
||||||
|
$('#bom-upload').hide();
|
||||||
|
|
||||||
|
$('#bom-submit').show();
|
||||||
|
|
||||||
|
constructBomUploadTable(response);
|
||||||
|
|
||||||
|
$('#bom-submit').click(function() {
|
||||||
|
submitBomTable({{ part.pk }}, {
|
||||||
|
bom_data: response,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
{% endblock js_ready %}
|
||||||
@@ -293,7 +293,7 @@ def progress_bar(val, max, *args, **kwargs):
|
|||||||
Render a progress bar element
|
Render a progress bar element
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id = kwargs.get('id', 'progress-bar')
|
item_id = kwargs.get('id', 'progress-bar')
|
||||||
|
|
||||||
if val > max:
|
if val > max:
|
||||||
style = 'progress-bar-over'
|
style = 'progress-bar-over'
|
||||||
@@ -317,7 +317,7 @@ def progress_bar(val, max, *args, **kwargs):
|
|||||||
style_tags.append(f'max-width: {max_width};')
|
style_tags.append(f'max-width: {max_width};')
|
||||||
|
|
||||||
html = f"""
|
html = f"""
|
||||||
<div id='{id}' class='progress' style='{" ".join(style_tags)}'>
|
<div id='{item_id}' class='progress' style='{" ".join(style_tags)}'>
|
||||||
<div class='progress-bar {style}' role='progressbar' aria-valuemin='0' aria-valuemax='100' style='width:{percent}%'></div>
|
<div class='progress-bar {style}' role='progressbar' aria-valuemin='0' aria-valuemax='100' style='width:{percent}%'></div>
|
||||||
<div class='progress-value'>{val} / {max}</div>
|
<div class='progress-value'>{val} / {max}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -451,8 +451,17 @@ class I18nStaticNode(StaticNode):
|
|||||||
replaces a variable named *lng* in the path with the current language
|
replaces a variable named *lng* in the path with the current language
|
||||||
"""
|
"""
|
||||||
def render(self, context):
|
def render(self, context):
|
||||||
self.path.var = self.path.var.format(lng=context.request.LANGUAGE_CODE)
|
|
||||||
|
self.original = getattr(self, 'original', None)
|
||||||
|
|
||||||
|
if not self.original:
|
||||||
|
# Store the original (un-rendered) path template, as it gets overwritten below
|
||||||
|
self.original = self.path.var
|
||||||
|
|
||||||
|
self.path.var = self.original.format(lng=context.request.LANGUAGE_CODE)
|
||||||
|
|
||||||
ret = super().render(context)
|
ret = super().render(context)
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
@@ -480,4 +489,5 @@ else:
|
|||||||
# change path to called ressource
|
# change path to called ressource
|
||||||
bits[1] = f"'{loc_name}/{{lng}}.{bits[1][1:-1]}'"
|
bits[1] = f"'{loc_name}/{{lng}}.{bits[1][1:-1]}'"
|
||||||
token.contents = ' '.join(bits)
|
token.contents = ' '.join(bits)
|
||||||
|
|
||||||
return I18nStaticNode.handle_token(parser, token)
|
return I18nStaticNode.handle_token(parser, token)
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class BomExportTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'file_format': 'csv',
|
'format': 'csv',
|
||||||
'cascade': True,
|
'cascade': True,
|
||||||
'parameter_data': True,
|
'parameter_data': True,
|
||||||
'stock_data': True,
|
'stock_data': True,
|
||||||
@@ -154,7 +154,9 @@ class BomExportTest(TestCase):
|
|||||||
'inherited',
|
'inherited',
|
||||||
'allow_variants',
|
'allow_variants',
|
||||||
'Default Location',
|
'Default Location',
|
||||||
|
'Total Stock',
|
||||||
'Available Stock',
|
'Available Stock',
|
||||||
|
'On Order',
|
||||||
]
|
]
|
||||||
|
|
||||||
for header in expected:
|
for header in expected:
|
||||||
@@ -169,7 +171,7 @@ class BomExportTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'file_format': 'xls',
|
'format': 'xls',
|
||||||
'cascade': True,
|
'cascade': True,
|
||||||
'parameter_data': True,
|
'parameter_data': True,
|
||||||
'stock_data': True,
|
'stock_data': True,
|
||||||
@@ -190,7 +192,7 @@ class BomExportTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'file_format': 'xlsx',
|
'format': 'xlsx',
|
||||||
'cascade': True,
|
'cascade': True,
|
||||||
'parameter_data': True,
|
'parameter_data': True,
|
||||||
'stock_data': True,
|
'stock_data': True,
|
||||||
@@ -208,7 +210,7 @@ class BomExportTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'file_format': 'json',
|
'format': 'json',
|
||||||
'cascade': True,
|
'cascade': True,
|
||||||
'parameter_data': True,
|
'parameter_data': True,
|
||||||
'stock_data': True,
|
'stock_data': True,
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"""
|
||||||
|
Unit testing for BOM upload / import functionality
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tablib
|
||||||
|
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
|
|
||||||
|
from part.models import Part
|
||||||
|
|
||||||
|
|
||||||
|
class BomUploadTest(InvenTreeAPITestCase):
|
||||||
|
"""
|
||||||
|
Test BOM file upload API endpoint
|
||||||
|
"""
|
||||||
|
|
||||||
|
roles = [
|
||||||
|
'part.add',
|
||||||
|
'part.change',
|
||||||
|
]
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.part = Part.objects.create(
|
||||||
|
name='Assembly',
|
||||||
|
description='An assembled part',
|
||||||
|
assembly=True,
|
||||||
|
component=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
for i in range(10):
|
||||||
|
Part.objects.create(
|
||||||
|
name=f"Component {i}",
|
||||||
|
IPN=f"CMP_{i}",
|
||||||
|
description="A subcomponent that can be used in a BOM",
|
||||||
|
component=True,
|
||||||
|
assembly=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.url = reverse('api-bom-extract')
|
||||||
|
|
||||||
|
def post_bom(self, filename, file_data, part=None, clear_existing=None, expected_code=None, content_type='text/plain'):
|
||||||
|
|
||||||
|
bom_file = SimpleUploadedFile(
|
||||||
|
filename,
|
||||||
|
file_data,
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
if part is None:
|
||||||
|
part = self.part.pk
|
||||||
|
|
||||||
|
if clear_existing is None:
|
||||||
|
clear_existing = False
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
self.url,
|
||||||
|
data={
|
||||||
|
'bom_file': bom_file,
|
||||||
|
'part': part,
|
||||||
|
'clear_existing': clear_existing,
|
||||||
|
},
|
||||||
|
expected_code=expected_code,
|
||||||
|
format='multipart',
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def test_missing_file(self):
|
||||||
|
"""
|
||||||
|
POST without a file
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
self.url,
|
||||||
|
data={},
|
||||||
|
expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('No file was submitted', str(response.data['bom_file']))
|
||||||
|
self.assertIn('This field is required', str(response.data['part']))
|
||||||
|
self.assertIn('This field is required', str(response.data['clear_existing']))
|
||||||
|
|
||||||
|
def test_unsupported_file(self):
|
||||||
|
"""
|
||||||
|
POST with an unsupported file type
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.post_bom(
|
||||||
|
'sample.txt',
|
||||||
|
b'hello world',
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('Unsupported file type', str(response.data['bom_file']))
|
||||||
|
|
||||||
|
def test_broken_file(self):
|
||||||
|
"""
|
||||||
|
Test upload with broken (corrupted) files
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.post_bom(
|
||||||
|
'sample.csv',
|
||||||
|
b'',
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('The submitted file is empty', str(response.data['bom_file']))
|
||||||
|
|
||||||
|
response = self.post_bom(
|
||||||
|
'test.xls',
|
||||||
|
b'hello world',
|
||||||
|
expected_code=400,
|
||||||
|
content_type='application/xls',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('Unsupported format, or corrupt file', str(response.data['bom_file']))
|
||||||
|
|
||||||
|
def test_invalid_upload(self):
|
||||||
|
"""
|
||||||
|
Test upload of an invalid file
|
||||||
|
"""
|
||||||
|
|
||||||
|
dataset = tablib.Dataset()
|
||||||
|
|
||||||
|
dataset.headers = [
|
||||||
|
'apple',
|
||||||
|
'banana',
|
||||||
|
]
|
||||||
|
|
||||||
|
response = self.post_bom(
|
||||||
|
'test.csv',
|
||||||
|
bytes(dataset.csv, 'utf8'),
|
||||||
|
content_type='text/csv',
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("Missing required column: 'quantity'", str(response.data))
|
||||||
|
|
||||||
|
# Try again, with an .xlsx file
|
||||||
|
response = self.post_bom(
|
||||||
|
'bom.xlsx',
|
||||||
|
dataset.xlsx,
|
||||||
|
content_type='application/xlsx',
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("Missing required column: 'quantity'", str(response.data))
|
||||||
|
|
||||||
|
# Add the quantity field (or close enough)
|
||||||
|
dataset.headers.append('quAntiTy ')
|
||||||
|
|
||||||
|
response = self.post_bom(
|
||||||
|
'test.csv',
|
||||||
|
bytes(dataset.csv, 'utf8'),
|
||||||
|
content_type='text/csv',
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('No part column found', str(response.data))
|
||||||
|
|
||||||
|
dataset.headers.append('part_id')
|
||||||
|
dataset.headers.append('part_name')
|
||||||
|
|
||||||
|
response = self.post_bom(
|
||||||
|
'test.csv',
|
||||||
|
bytes(dataset.csv, 'utf8'),
|
||||||
|
content_type='text/csv',
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('No data rows found', str(response.data))
|
||||||
|
|
||||||
|
def test_invalid_data(self):
|
||||||
|
"""
|
||||||
|
Upload data which contains errors
|
||||||
|
"""
|
||||||
|
|
||||||
|
dataset = tablib.Dataset()
|
||||||
|
|
||||||
|
# Only these headers are strictly necessary
|
||||||
|
dataset.headers = ['part_id', 'quantity']
|
||||||
|
|
||||||
|
components = Part.objects.filter(component=True)
|
||||||
|
|
||||||
|
for idx, cmp in enumerate(components):
|
||||||
|
|
||||||
|
if idx == 5:
|
||||||
|
cmp.component = False
|
||||||
|
cmp.save()
|
||||||
|
|
||||||
|
dataset.append([cmp.pk, idx])
|
||||||
|
|
||||||
|
# Add a duplicate part too
|
||||||
|
dataset.append([components.first().pk, 'invalid'])
|
||||||
|
|
||||||
|
response = self.post_bom(
|
||||||
|
'test.csv',
|
||||||
|
bytes(dataset.csv, 'utf8'),
|
||||||
|
content_type='text/csv',
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
errors = response.data['errors']
|
||||||
|
|
||||||
|
self.assertIn('Quantity must be greater than zero', str(errors[0]))
|
||||||
|
self.assertIn('Part is not designated as a component', str(errors[5]))
|
||||||
|
self.assertIn('Duplicate part selected', str(errors[-1]))
|
||||||
|
self.assertIn('Invalid quantity', str(errors[-1]))
|
||||||
|
|
||||||
|
for idx, row in enumerate(response.data['rows'][:-1]):
|
||||||
|
self.assertEqual(str(row['part']), str(components[idx].pk))
|
||||||
|
|
||||||
|
def test_part_guess(self):
|
||||||
|
"""
|
||||||
|
Test part 'guessing' when PK values are not supplied
|
||||||
|
"""
|
||||||
|
|
||||||
|
dataset = tablib.Dataset()
|
||||||
|
|
||||||
|
# Should be able to 'guess' the part from the name
|
||||||
|
dataset.headers = ['part_name', 'quantity']
|
||||||
|
|
||||||
|
components = Part.objects.filter(component=True)
|
||||||
|
|
||||||
|
for idx, cmp in enumerate(components):
|
||||||
|
dataset.append([
|
||||||
|
f"Component {idx}",
|
||||||
|
10,
|
||||||
|
])
|
||||||
|
|
||||||
|
response = self.post_bom(
|
||||||
|
'test.csv',
|
||||||
|
bytes(dataset.csv, 'utf8'),
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = response.data['rows']
|
||||||
|
|
||||||
|
self.assertEqual(len(rows), 10)
|
||||||
|
|
||||||
|
for idx in range(10):
|
||||||
|
self.assertEqual(rows[idx]['part'], components[idx].pk)
|
||||||
|
|
||||||
|
# Should also be able to 'guess' part by the IPN value
|
||||||
|
dataset = tablib.Dataset()
|
||||||
|
|
||||||
|
dataset.headers = ['part_ipn', 'quantity']
|
||||||
|
|
||||||
|
for idx, cmp in enumerate(components):
|
||||||
|
dataset.append([
|
||||||
|
f"CMP_{idx}",
|
||||||
|
10,
|
||||||
|
])
|
||||||
|
|
||||||
|
response = self.post_bom(
|
||||||
|
'test.csv',
|
||||||
|
bytes(dataset.csv, 'utf8'),
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = response.data['rows']
|
||||||
|
|
||||||
|
self.assertEqual(len(rows), 10)
|
||||||
|
|
||||||
|
for idx in range(10):
|
||||||
|
self.assertEqual(rows[idx]['part'], components[idx].pk)
|
||||||
|
|
||||||
|
def test_levels(self):
|
||||||
|
"""
|
||||||
|
Test that multi-level BOMs are correctly handled during upload
|
||||||
|
"""
|
||||||
|
|
||||||
|
dataset = tablib.Dataset()
|
||||||
|
|
||||||
|
dataset.headers = ['level', 'part', 'quantity']
|
||||||
|
|
||||||
|
components = Part.objects.filter(component=True)
|
||||||
|
|
||||||
|
for idx, cmp in enumerate(components):
|
||||||
|
dataset.append([
|
||||||
|
idx % 3,
|
||||||
|
cmp.pk,
|
||||||
|
2,
|
||||||
|
])
|
||||||
|
|
||||||
|
response = self.post_bom(
|
||||||
|
'test.csv',
|
||||||
|
bytes(dataset.csv, 'utf8'),
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only parts at index 1, 4, 7 should have been returned
|
||||||
|
self.assertEqual(len(response.data['rows']), 3)
|
||||||
@@ -31,8 +31,8 @@ class TemplateTagTest(TestCase):
|
|||||||
self.assertEqual(type(inventree_extras.inventree_version()), str)
|
self.assertEqual(type(inventree_extras.inventree_version()), str)
|
||||||
|
|
||||||
def test_hash(self):
|
def test_hash(self):
|
||||||
hash = inventree_extras.inventree_commit_hash()
|
result_hash = inventree_extras.inventree_commit_hash()
|
||||||
self.assertGreater(len(hash), 5)
|
self.assertGreater(len(result_hash), 5)
|
||||||
|
|
||||||
def test_date(self):
|
def test_date(self):
|
||||||
d = inventree_extras.inventree_commit_date()
|
d = inventree_extras.inventree_commit_date()
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
|
|
||||||
class SupplierPartTest(TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
pass
|
|
||||||
@@ -33,7 +33,6 @@ part_parameter_urls = [
|
|||||||
|
|
||||||
part_detail_urls = [
|
part_detail_urls = [
|
||||||
url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'),
|
url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'),
|
||||||
url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'),
|
|
||||||
url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
|
url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
|
||||||
|
|
||||||
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
|
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
|
||||||
|
|||||||
+8
-317
@@ -28,20 +28,17 @@ import requests
|
|||||||
import os
|
import os
|
||||||
import io
|
import io
|
||||||
|
|
||||||
from rapidfuzz import fuzz
|
from decimal import Decimal
|
||||||
from decimal import Decimal, InvalidOperation
|
|
||||||
|
|
||||||
from .models import PartCategory, Part
|
from .models import PartCategory, Part
|
||||||
from .models import PartParameterTemplate
|
from .models import PartParameterTemplate
|
||||||
from .models import PartCategoryParameterTemplate
|
from .models import PartCategoryParameterTemplate
|
||||||
from .models import BomItem
|
|
||||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
from common.files import FileManager
|
from common.files import FileManager
|
||||||
from common.views import FileManagementFormView, FileManagementAjaxView
|
from common.views import FileManagementFormView, FileManagementAjaxView
|
||||||
from common.forms import UploadFileForm, MatchFieldForm
|
|
||||||
|
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
|
|
||||||
@@ -395,6 +392,7 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
|
|||||||
context.update(**ctx)
|
context.update(**ctx)
|
||||||
|
|
||||||
# Pricing information
|
# Pricing information
|
||||||
|
if InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False):
|
||||||
ctx = self.get_pricing(self.get_quantity())
|
ctx = self.get_pricing(self.get_quantity())
|
||||||
ctx['form'] = self.form_class(initial=self.get_initials())
|
ctx['form'] = self.form_class(initial=self.get_initials())
|
||||||
|
|
||||||
@@ -703,270 +701,12 @@ class PartImageSelect(AjaxUpdateView):
|
|||||||
return self.renderJsonResponse(request, form, data)
|
return self.renderJsonResponse(request, form, data)
|
||||||
|
|
||||||
|
|
||||||
class BomUpload(InvenTreeRoleMixin, FileManagementFormView):
|
class BomUpload(InvenTreeRoleMixin, DetailView):
|
||||||
""" View for uploading a BOM file, and handling BOM data importing.
|
""" View for uploading a BOM file, and handling BOM data importing. """
|
||||||
|
|
||||||
The BOM upload process is as follows:
|
context_object_name = 'part'
|
||||||
|
queryset = Part.objects.all()
|
||||||
1. (Client) Select and upload BOM file
|
template_name = 'part/upload_bom.html'
|
||||||
2. (Server) Verify that supplied file is a file compatible with tablib library
|
|
||||||
3. (Server) Introspect data file, try to find sensible columns / values / etc
|
|
||||||
4. (Server) Send suggestions back to the client
|
|
||||||
5. (Client) Makes choices based on suggestions:
|
|
||||||
- Accept automatic matching to parts found in database
|
|
||||||
- Accept suggestions for 'partial' or 'fuzzy' matches
|
|
||||||
- Create new parts in case of parts not being available
|
|
||||||
6. (Client) Sends updated dataset back to server
|
|
||||||
7. (Server) Check POST data for validity, sanity checking, etc.
|
|
||||||
8. (Server) Respond to POST request
|
|
||||||
- If data are valid, proceed to 9.
|
|
||||||
- If data not valid, return to 4.
|
|
||||||
9. (Server) Send confirmation form to user
|
|
||||||
- Display the actions which will occur
|
|
||||||
- Provide final "CONFIRM" button
|
|
||||||
10. (Client) Confirm final changes
|
|
||||||
11. (Server) Apply changes to database, update BOM items.
|
|
||||||
|
|
||||||
During these steps, data are passed between the server/client as JSON objects.
|
|
||||||
"""
|
|
||||||
|
|
||||||
role_required = ('part.change', 'part.add')
|
|
||||||
|
|
||||||
class BomFileManager(FileManager):
|
|
||||||
# Fields which are absolutely necessary for valid upload
|
|
||||||
REQUIRED_HEADERS = [
|
|
||||||
'Quantity'
|
|
||||||
]
|
|
||||||
|
|
||||||
# Fields which are used for part matching (only one of them is needed)
|
|
||||||
ITEM_MATCH_HEADERS = [
|
|
||||||
'Part_Name',
|
|
||||||
'Part_IPN',
|
|
||||||
'Part_ID',
|
|
||||||
]
|
|
||||||
|
|
||||||
# Fields which would be helpful but are not required
|
|
||||||
OPTIONAL_HEADERS = [
|
|
||||||
'Reference',
|
|
||||||
'Note',
|
|
||||||
'Overage',
|
|
||||||
]
|
|
||||||
|
|
||||||
EDITABLE_HEADERS = [
|
|
||||||
'Reference',
|
|
||||||
'Note',
|
|
||||||
'Overage'
|
|
||||||
]
|
|
||||||
|
|
||||||
name = 'order'
|
|
||||||
form_list = [
|
|
||||||
('upload', UploadFileForm),
|
|
||||||
('fields', MatchFieldForm),
|
|
||||||
('items', part_forms.BomMatchItemForm),
|
|
||||||
]
|
|
||||||
form_steps_template = [
|
|
||||||
'part/bom_upload/upload_file.html',
|
|
||||||
'part/bom_upload/match_fields.html',
|
|
||||||
'part/bom_upload/match_parts.html',
|
|
||||||
]
|
|
||||||
form_steps_description = [
|
|
||||||
_("Upload File"),
|
|
||||||
_("Match Fields"),
|
|
||||||
_("Match Parts"),
|
|
||||||
]
|
|
||||||
form_field_map = {
|
|
||||||
'item_select': 'part',
|
|
||||||
'quantity': 'quantity',
|
|
||||||
'overage': 'overage',
|
|
||||||
'reference': 'reference',
|
|
||||||
'note': 'note',
|
|
||||||
}
|
|
||||||
file_manager_class = BomFileManager
|
|
||||||
|
|
||||||
def get_part(self):
|
|
||||||
""" Get part or return 404 """
|
|
||||||
|
|
||||||
return get_object_or_404(Part, pk=self.kwargs['pk'])
|
|
||||||
|
|
||||||
def get_context_data(self, form, **kwargs):
|
|
||||||
""" Handle context data for order """
|
|
||||||
|
|
||||||
context = super().get_context_data(form=form, **kwargs)
|
|
||||||
|
|
||||||
part = self.get_part()
|
|
||||||
|
|
||||||
context.update({'part': part})
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def get_allowed_parts(self):
|
|
||||||
""" Return a queryset of parts which are allowed to be added to this BOM.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.get_part().get_allowed_bom_items()
|
|
||||||
|
|
||||||
def get_field_selection(self):
|
|
||||||
""" Once data columns have been selected, attempt to pre-select the proper data from the database.
|
|
||||||
This function is called once the field selection has been validated.
|
|
||||||
The pre-fill data are then passed through to the part selection form.
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.allowed_items = self.get_allowed_parts()
|
|
||||||
|
|
||||||
# Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database
|
|
||||||
k_idx = self.get_column_index('Part_ID')
|
|
||||||
p_idx = self.get_column_index('Part_Name')
|
|
||||||
i_idx = self.get_column_index('Part_IPN')
|
|
||||||
|
|
||||||
q_idx = self.get_column_index('Quantity')
|
|
||||||
r_idx = self.get_column_index('Reference')
|
|
||||||
o_idx = self.get_column_index('Overage')
|
|
||||||
n_idx = self.get_column_index('Note')
|
|
||||||
|
|
||||||
for row in self.rows:
|
|
||||||
"""
|
|
||||||
Iterate through each row in the uploaded data,
|
|
||||||
and see if we can match the row to a "Part" object in the database.
|
|
||||||
There are three potential ways to match, based on the uploaded data:
|
|
||||||
a) Use the PK (primary key) field for the part, uploaded in the "Part_ID" field
|
|
||||||
b) Use the IPN (internal part number) field for the part, uploaded in the "Part_IPN" field
|
|
||||||
c) Use the name of the part, uploaded in the "Part_Name" field
|
|
||||||
Notes:
|
|
||||||
- If using the Part_ID field, we can do an exact match against the PK field
|
|
||||||
- If using the Part_IPN field, we can do an exact match against the IPN field
|
|
||||||
- If using the Part_Name field, we can use fuzzy string matching to match "close" values
|
|
||||||
We also extract other information from the row, for the other non-matched fields:
|
|
||||||
- Quantity
|
|
||||||
- Reference
|
|
||||||
- Overage
|
|
||||||
- Note
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Initially use a quantity of zero
|
|
||||||
quantity = Decimal(0)
|
|
||||||
|
|
||||||
# Initially we do not have a part to reference
|
|
||||||
exact_match_part = None
|
|
||||||
|
|
||||||
# A list of potential Part matches
|
|
||||||
part_options = self.allowed_items
|
|
||||||
|
|
||||||
# Check if there is a column corresponding to "quantity"
|
|
||||||
if q_idx >= 0:
|
|
||||||
q_val = row['data'][q_idx]['cell']
|
|
||||||
|
|
||||||
if q_val:
|
|
||||||
# Delete commas
|
|
||||||
q_val = q_val.replace(',', '')
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Attempt to extract a valid quantity from the field
|
|
||||||
quantity = Decimal(q_val)
|
|
||||||
# Store the 'quantity' value
|
|
||||||
row['quantity'] = quantity
|
|
||||||
except (ValueError, InvalidOperation):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check if there is a column corresponding to "PK"
|
|
||||||
if k_idx >= 0:
|
|
||||||
pk = row['data'][k_idx]['cell']
|
|
||||||
|
|
||||||
if pk:
|
|
||||||
try:
|
|
||||||
# Attempt Part lookup based on PK value
|
|
||||||
exact_match_part = self.allowed_items.get(pk=pk)
|
|
||||||
except (ValueError, Part.DoesNotExist):
|
|
||||||
exact_match_part = None
|
|
||||||
|
|
||||||
# Check if there is a column corresponding to "Part IPN" and no exact match found yet
|
|
||||||
if i_idx >= 0 and not exact_match_part:
|
|
||||||
part_ipn = row['data'][i_idx]['cell']
|
|
||||||
|
|
||||||
if part_ipn:
|
|
||||||
part_matches = [part for part in self.allowed_items if part.IPN and part_ipn.lower() == str(part.IPN.lower())]
|
|
||||||
|
|
||||||
# Check for single match
|
|
||||||
if len(part_matches) == 1:
|
|
||||||
exact_match_part = part_matches[0]
|
|
||||||
|
|
||||||
# Check if there is a column corresponding to "Part Name" and no exact match found yet
|
|
||||||
if p_idx >= 0 and not exact_match_part:
|
|
||||||
part_name = row['data'][p_idx]['cell']
|
|
||||||
|
|
||||||
row['part_name'] = part_name
|
|
||||||
|
|
||||||
matches = []
|
|
||||||
|
|
||||||
for part in self.allowed_items:
|
|
||||||
ratio = fuzz.partial_ratio(part.name + part.description, part_name)
|
|
||||||
matches.append({'part': part, 'match': ratio})
|
|
||||||
|
|
||||||
# Sort matches by the 'strength' of the match ratio
|
|
||||||
if len(matches) > 0:
|
|
||||||
matches = sorted(matches, key=lambda item: item['match'], reverse=True)
|
|
||||||
|
|
||||||
part_options = [m['part'] for m in matches]
|
|
||||||
|
|
||||||
# Supply list of part options for each row, sorted by how closely they match the part name
|
|
||||||
row['item_options'] = part_options
|
|
||||||
|
|
||||||
# Unless found, the 'item_match' is blank
|
|
||||||
row['item_match'] = None
|
|
||||||
|
|
||||||
if exact_match_part:
|
|
||||||
# If there is an exact match based on PK or IPN, use that
|
|
||||||
row['item_match'] = exact_match_part
|
|
||||||
|
|
||||||
# Check if there is a column corresponding to "Overage" field
|
|
||||||
if o_idx >= 0:
|
|
||||||
row['overage'] = row['data'][o_idx]['cell']
|
|
||||||
|
|
||||||
# Check if there is a column corresponding to "Reference" field
|
|
||||||
if r_idx >= 0:
|
|
||||||
row['reference'] = row['data'][r_idx]['cell']
|
|
||||||
|
|
||||||
# Check if there is a column corresponding to "Note" field
|
|
||||||
if n_idx >= 0:
|
|
||||||
row['note'] = row['data'][n_idx]['cell']
|
|
||||||
|
|
||||||
def done(self, form_list, **kwargs):
|
|
||||||
""" Once all the data is in, process it to add BomItem instances to the part """
|
|
||||||
|
|
||||||
self.part = self.get_part()
|
|
||||||
items = self.get_clean_items()
|
|
||||||
|
|
||||||
# Clear BOM
|
|
||||||
self.part.clear_bom()
|
|
||||||
|
|
||||||
# Generate new BOM items
|
|
||||||
for bom_item in items.values():
|
|
||||||
try:
|
|
||||||
part = Part.objects.get(pk=int(bom_item.get('part')))
|
|
||||||
except (ValueError, Part.DoesNotExist):
|
|
||||||
continue
|
|
||||||
|
|
||||||
quantity = bom_item.get('quantity')
|
|
||||||
overage = bom_item.get('overage', '')
|
|
||||||
reference = bom_item.get('reference', '')
|
|
||||||
note = bom_item.get('note', '')
|
|
||||||
|
|
||||||
# Create a new BOM item
|
|
||||||
item = BomItem(
|
|
||||||
part=self.part,
|
|
||||||
sub_part=part,
|
|
||||||
quantity=quantity,
|
|
||||||
overage=overage,
|
|
||||||
reference=reference,
|
|
||||||
note=note,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
item.save()
|
|
||||||
except IntegrityError:
|
|
||||||
# BomItem already exists
|
|
||||||
pass
|
|
||||||
|
|
||||||
return HttpResponseRedirect(reverse('part-detail', kwargs={'pk': self.kwargs['pk']}))
|
|
||||||
|
|
||||||
|
|
||||||
class PartExport(AjaxView):
|
class PartExport(AjaxView):
|
||||||
@@ -1059,7 +799,7 @@ class BomDownload(AjaxView):
|
|||||||
|
|
||||||
part = get_object_or_404(Part, pk=self.kwargs['pk'])
|
part = get_object_or_404(Part, pk=self.kwargs['pk'])
|
||||||
|
|
||||||
export_format = request.GET.get('file_format', 'csv')
|
export_format = request.GET.get('format', 'csv')
|
||||||
|
|
||||||
cascade = str2bool(request.GET.get('cascade', False))
|
cascade = str2bool(request.GET.get('cascade', False))
|
||||||
|
|
||||||
@@ -1102,55 +842,6 @@ class BomDownload(AjaxView):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class BomExport(AjaxView):
|
|
||||||
""" Provide a simple form to allow the user to select BOM download options.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = Part
|
|
||||||
ajax_form_title = _("Export Bill of Materials")
|
|
||||||
|
|
||||||
role_required = 'part.view'
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
|
|
||||||
# Extract POSTed form data
|
|
||||||
fmt = request.POST.get('file_format', 'csv').lower()
|
|
||||||
cascade = str2bool(request.POST.get('cascading', False))
|
|
||||||
levels = request.POST.get('levels', None)
|
|
||||||
parameter_data = str2bool(request.POST.get('parameter_data', False))
|
|
||||||
stock_data = str2bool(request.POST.get('stock_data', False))
|
|
||||||
supplier_data = str2bool(request.POST.get('supplier_data', False))
|
|
||||||
manufacturer_data = str2bool(request.POST.get('manufacturer_data', False))
|
|
||||||
|
|
||||||
try:
|
|
||||||
part = Part.objects.get(pk=self.kwargs['pk'])
|
|
||||||
except:
|
|
||||||
part = None
|
|
||||||
|
|
||||||
# Format a URL to redirect to
|
|
||||||
if part:
|
|
||||||
url = reverse('bom-download', kwargs={'pk': part.pk})
|
|
||||||
else:
|
|
||||||
url = ''
|
|
||||||
|
|
||||||
url += '?file_format=' + fmt
|
|
||||||
url += '&cascade=' + str(cascade)
|
|
||||||
url += '¶meter_data=' + str(parameter_data)
|
|
||||||
url += '&stock_data=' + str(stock_data)
|
|
||||||
url += '&supplier_data=' + str(supplier_data)
|
|
||||||
url += '&manufacturer_data=' + str(manufacturer_data)
|
|
||||||
|
|
||||||
if levels:
|
|
||||||
url += '&levels=' + str(levels)
|
|
||||||
|
|
||||||
data = {
|
|
||||||
'form_valid': part is not None,
|
|
||||||
'url': url,
|
|
||||||
}
|
|
||||||
|
|
||||||
return self.renderJsonResponse(request, self.form_class(), data=data)
|
|
||||||
|
|
||||||
|
|
||||||
class PartDelete(AjaxDeleteView):
|
class PartDelete(AjaxDeleteView):
|
||||||
""" View to delete a Part object """
|
""" View to delete a Part object """
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from django.conf import settings
|
|||||||
|
|
||||||
from maintenance_mode.core import set_maintenance_mode
|
from maintenance_mode.core import set_maintenance_mode
|
||||||
|
|
||||||
|
from InvenTree.ready import isImportingData
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
|
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ class PluginAppConfig(AppConfig):
|
|||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
if settings.PLUGINS_ENABLED:
|
if settings.PLUGINS_ENABLED:
|
||||||
|
|
||||||
|
if isImportingData():
|
||||||
|
logger.info('Skipping plugin loading for data import')
|
||||||
|
else:
|
||||||
logger.info('Loading InvenTree plugins')
|
logger.info('Loading InvenTree plugins')
|
||||||
|
|
||||||
if not registry.is_loading:
|
if not registry.is_loading:
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ def hash_barcode(barcode_data):
|
|||||||
|
|
||||||
barcode_data = ''.join(list(printable_chars))
|
barcode_data = ''.join(list(printable_chars))
|
||||||
|
|
||||||
hash = hashlib.md5(str(barcode_data).encode())
|
result_hash = hashlib.md5(str(barcode_data).encode())
|
||||||
return str(hash.hexdigest())
|
return str(result_hash.hexdigest())
|
||||||
|
|
||||||
|
|
||||||
class BarcodeMixin:
|
class BarcodeMixin:
|
||||||
|
|||||||
@@ -75,10 +75,18 @@ class ScheduleMixin:
|
|||||||
'schedule': "I", # Schedule type (see django_q.Schedule)
|
'schedule': "I", # Schedule type (see django_q.Schedule)
|
||||||
'minutes': 30, # Number of minutes (only if schedule type = Minutes)
|
'minutes': 30, # Number of minutes (only if schedule type = Minutes)
|
||||||
'repeats': 5, # Number of repeats (leave blank for 'forever')
|
'repeats': 5, # Number of repeats (leave blank for 'forever')
|
||||||
}
|
},
|
||||||
|
'member_func': {
|
||||||
|
'func': 'my_class_func', # Note, without the 'dot' notation, it will call a class member function
|
||||||
|
'schedule': "H", # Once per hour
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Note: 'schedule' parameter must be one of ['I', 'H', 'D', 'W', 'M', 'Q', 'Y']
|
Note: 'schedule' parameter must be one of ['I', 'H', 'D', 'W', 'M', 'Q', 'Y']
|
||||||
|
|
||||||
|
Note: The 'func' argument can take two different forms:
|
||||||
|
- Dotted notation e.g. 'module.submodule.func' - calls a global function with the defined path
|
||||||
|
- Member notation e.g. 'my_func' (no dots!) - calls a member function of the calling class
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ALLOWABLE_SCHEDULE_TYPES = ['I', 'H', 'D', 'W', 'M', 'Q', 'Y']
|
ALLOWABLE_SCHEDULE_TYPES = ['I', 'H', 'D', 'W', 'M', 'Q', 'Y']
|
||||||
@@ -94,11 +102,14 @@ class ScheduleMixin:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('schedule', 'has_scheduled_tasks', __class__)
|
self.scheduled_tasks = self.get_scheduled_tasks()
|
||||||
self.scheduled_tasks = getattr(self, 'SCHEDULED_TASKS', {})
|
|
||||||
|
|
||||||
self.validate_scheduled_tasks()
|
self.validate_scheduled_tasks()
|
||||||
|
|
||||||
|
self.add_mixin('schedule', 'has_scheduled_tasks', __class__)
|
||||||
|
|
||||||
|
def get_scheduled_tasks(self):
|
||||||
|
return getattr(self, 'SCHEDULED_TASKS', {})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_scheduled_tasks(self):
|
def has_scheduled_tasks(self):
|
||||||
"""
|
"""
|
||||||
@@ -158,18 +169,46 @@ class ScheduleMixin:
|
|||||||
|
|
||||||
task_name = self.get_task_name(key)
|
task_name = self.get_task_name(key)
|
||||||
|
|
||||||
# If a matching scheduled task does not exist, create it!
|
if Schedule.objects.filter(name=task_name).exists():
|
||||||
if not Schedule.objects.filter(name=task_name).exists():
|
# Scheduled task already exists - continue!
|
||||||
|
continue
|
||||||
|
|
||||||
logger.info(f"Adding scheduled task '{task_name}'")
|
logger.info(f"Adding scheduled task '{task_name}'")
|
||||||
|
|
||||||
|
func_name = task['func'].strip()
|
||||||
|
|
||||||
|
if '.' in func_name:
|
||||||
|
"""
|
||||||
|
Dotted notation indicates that we wish to run a globally defined function,
|
||||||
|
from a specified Python module.
|
||||||
|
"""
|
||||||
|
|
||||||
Schedule.objects.create(
|
Schedule.objects.create(
|
||||||
name=task_name,
|
name=task_name,
|
||||||
func=task['func'],
|
func=func_name,
|
||||||
schedule_type=task['schedule'],
|
schedule_type=task['schedule'],
|
||||||
minutes=task.get('minutes', None),
|
minutes=task.get('minutes', None),
|
||||||
repeats=task.get('repeats', -1),
|
repeats=task.get('repeats', -1),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
"""
|
||||||
|
Non-dotted notation indicates that we wish to call a 'member function' of the calling plugin.
|
||||||
|
|
||||||
|
This is managed by the plugin registry itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
slug = self.plugin_slug()
|
||||||
|
|
||||||
|
Schedule.objects.create(
|
||||||
|
name=task_name,
|
||||||
|
func='plugin.registry.call_function',
|
||||||
|
args=f"'{slug}', '{func_name}'",
|
||||||
|
schedule_type=task['schedule'],
|
||||||
|
minutes=task.get('minutes', None),
|
||||||
|
repeats=task.get('repeats', -1),
|
||||||
|
)
|
||||||
|
|
||||||
except (ProgrammingError, OperationalError):
|
except (ProgrammingError, OperationalError):
|
||||||
# Database might not yet be ready
|
# Database might not yet be ready
|
||||||
logger.warning("register_tasks failed, database not ready")
|
logger.warning("register_tasks failed, database not ready")
|
||||||
|
|||||||
@@ -173,8 +173,8 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
|
|||||||
"""
|
"""
|
||||||
License of plugin
|
License of plugin
|
||||||
"""
|
"""
|
||||||
license = getattr(self, 'LICENSE', None)
|
lic = getattr(self, 'LICENSE', None)
|
||||||
return license
|
return lic
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.5 on 2022-01-28 22:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('plugin', '0003_pluginsetting'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='pluginsetting',
|
||||||
|
name='key',
|
||||||
|
field=models.CharField(help_text='Settings key (must be unique - case insensitive)', max_length=50),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -94,10 +94,8 @@ class PluginConfig(models.Model):
|
|||||||
ret = super().save(force_insert, force_update, *args, **kwargs)
|
ret = super().save(force_insert, force_update, *args, **kwargs)
|
||||||
|
|
||||||
if not reload:
|
if not reload:
|
||||||
if self.active is False and self.__org_active is True:
|
if (self.active is False and self.__org_active is True) or \
|
||||||
registry.reload_plugins()
|
(self.active is True and self.__org_active is False):
|
||||||
|
|
||||||
elif self.active is True and self.__org_active is False:
|
|
||||||
registry.reload_plugins()
|
registry.reload_plugins()
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
@@ -124,6 +122,12 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
|
|||||||
so that we can pass the plugin instance
|
so that we can pass the plugin instance
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def is_bool(self, **kwargs):
|
||||||
|
|
||||||
|
kwargs['plugin'] = self.plugin
|
||||||
|
|
||||||
|
return super().is_bool(**kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
return self.__class__.get_setting_name(self.key, plugin=self.plugin)
|
return self.__class__.get_setting_name(self.key, plugin=self.plugin)
|
||||||
|
|||||||
@@ -59,6 +59,22 @@ class PluginsRegistry:
|
|||||||
# mixins
|
# mixins
|
||||||
self.mixins_settings = {}
|
self.mixins_settings = {}
|
||||||
|
|
||||||
|
def call_plugin_function(self, slug, func, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Call a member function (named by 'func') of the plugin named by 'slug'.
|
||||||
|
|
||||||
|
As this is intended to be run by the background worker,
|
||||||
|
we do not perform any try/except here.
|
||||||
|
|
||||||
|
Instead, any error messages are returned to the worker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
plugin = self.plugins[slug]
|
||||||
|
|
||||||
|
plugin_func = getattr(plugin, func)
|
||||||
|
|
||||||
|
return plugin_func(*args, **kwargs)
|
||||||
|
|
||||||
# region public functions
|
# region public functions
|
||||||
# region loading / unloading
|
# region loading / unloading
|
||||||
def load_plugins(self):
|
def load_plugins(self):
|
||||||
@@ -374,6 +390,10 @@ class PluginsRegistry:
|
|||||||
logger.warning("activate_integration_schedule failed, database not ready")
|
logger.warning("activate_integration_schedule failed, database not ready")
|
||||||
|
|
||||||
def deactivate_integration_schedule(self):
|
def deactivate_integration_schedule(self):
|
||||||
|
"""
|
||||||
|
Deactivate ScheduleMixin
|
||||||
|
currently nothing is done
|
||||||
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def activate_integration_app(self, plugins, force_reload=False):
|
def activate_integration_app(self, plugins, force_reload=False):
|
||||||
@@ -557,3 +577,8 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
|
|
||||||
registry = PluginsRegistry()
|
registry = PluginsRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
def call_function(plugin_name, function_name, *args, **kwargs):
|
||||||
|
""" Global helper function to call a specific member function of a plugin """
|
||||||
|
return registry.call_plugin_function(plugin_name, function_name, *args, **kwargs)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Sample plugin which supports task scheduling
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from plugin import IntegrationPluginBase
|
from plugin import IntegrationPluginBase
|
||||||
from plugin.mixins import ScheduleMixin
|
from plugin.mixins import ScheduleMixin, SettingsMixin
|
||||||
|
|
||||||
|
|
||||||
# Define some simple tasks to perform
|
# Define some simple tasks to perform
|
||||||
@@ -15,7 +15,7 @@ def print_world():
|
|||||||
print("World")
|
print("World")
|
||||||
|
|
||||||
|
|
||||||
class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase):
|
class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, IntegrationPluginBase):
|
||||||
"""
|
"""
|
||||||
A sample plugin which provides support for scheduled tasks
|
A sample plugin which provides support for scheduled tasks
|
||||||
"""
|
"""
|
||||||
@@ -25,6 +25,11 @@ class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase):
|
|||||||
PLUGIN_TITLE = "Scheduled Tasks"
|
PLUGIN_TITLE = "Scheduled Tasks"
|
||||||
|
|
||||||
SCHEDULED_TASKS = {
|
SCHEDULED_TASKS = {
|
||||||
|
'member': {
|
||||||
|
'func': 'member_func',
|
||||||
|
'schedule': 'I',
|
||||||
|
'minutes': 30,
|
||||||
|
},
|
||||||
'hello': {
|
'hello': {
|
||||||
'func': 'plugin.samples.integration.scheduled_task.print_hello',
|
'func': 'plugin.samples.integration.scheduled_task.print_hello',
|
||||||
'schedule': 'I',
|
'schedule': 'I',
|
||||||
@@ -35,3 +40,21 @@ class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase):
|
|||||||
'schedule': 'H',
|
'schedule': 'H',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SETTINGS = {
|
||||||
|
'T_OR_F': {
|
||||||
|
'name': 'True or False',
|
||||||
|
'description': 'Print true or false when running the periodic task',
|
||||||
|
'validator': bool,
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def member_func(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
A simple member function to demonstrate functionality
|
||||||
|
"""
|
||||||
|
|
||||||
|
t_or_f = self.get_setting('T_OR_F')
|
||||||
|
|
||||||
|
print(f"Called member_func - value is {t_or_f}")
|
||||||
|
|||||||
+53
-48
@@ -5,6 +5,7 @@ JSON API for the Stock app
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
@@ -463,13 +464,10 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
data = request.data
|
|
||||||
|
|
||||||
serializer = self.get_serializer(data=data)
|
# Copy the request data, to side-step "mutability" issues
|
||||||
serializer.is_valid(raise_exception=True)
|
data = OrderedDict()
|
||||||
|
data.update(request.data)
|
||||||
# Check if a set of serial numbers was provided
|
|
||||||
serial_numbers = data.get('serial_numbers', '')
|
|
||||||
|
|
||||||
quantity = data.get('quantity', None)
|
quantity = data.get('quantity', None)
|
||||||
|
|
||||||
@@ -478,77 +476,84 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
'quantity': _('Quantity is required'),
|
'quantity': _('Quantity is required'),
|
||||||
})
|
})
|
||||||
|
|
||||||
notes = data.get('notes', '')
|
try:
|
||||||
|
part = Part.objects.get(pk=data.get('part', None))
|
||||||
|
except (ValueError, Part.DoesNotExist):
|
||||||
|
raise ValidationError({
|
||||||
|
'part': _('Valid part must be supplied'),
|
||||||
|
})
|
||||||
|
|
||||||
with transaction.atomic():
|
# Set default location (if not provided)
|
||||||
|
|
||||||
# Create an initial stock item
|
|
||||||
item = serializer.save()
|
|
||||||
|
|
||||||
# A location was *not* specified - try to infer it
|
|
||||||
if 'location' not in data:
|
if 'location' not in data:
|
||||||
item.location = item.part.get_default_location()
|
location = part.get_default_location()
|
||||||
|
|
||||||
|
if location:
|
||||||
|
data['location'] = location.pk
|
||||||
|
|
||||||
# An expiry date was *not* specified - try to infer it!
|
# An expiry date was *not* specified - try to infer it!
|
||||||
if 'expiry_date' not in data:
|
if 'expiry_date' not in data:
|
||||||
|
|
||||||
if item.part.default_expiry > 0:
|
if part.default_expiry > 0:
|
||||||
item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry)
|
data['expiry_date'] = datetime.now().date() + timedelta(days=part.default_expiry)
|
||||||
|
|
||||||
# fetch serial numbers
|
# Attempt to extract serial numbers from submitted data
|
||||||
serials = None
|
serials = None
|
||||||
|
|
||||||
if serial_numbers:
|
# Check if a set of serial numbers was provided
|
||||||
|
serial_numbers = data.get('serial_numbers', '')
|
||||||
|
|
||||||
|
# Assign serial numbers for a trackable part
|
||||||
|
if serial_numbers and part.trackable:
|
||||||
|
|
||||||
# If serial numbers are specified, check that they match!
|
# If serial numbers are specified, check that they match!
|
||||||
try:
|
try:
|
||||||
serials = extract_serial_numbers(serial_numbers, quantity, item.part.getLatestSerialNumberInt())
|
serials = extract_serial_numbers(serial_numbers, quantity, part.getLatestSerialNumberInt())
|
||||||
except DjangoValidationError as e:
|
except DjangoValidationError as e:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'quantity': e.messages,
|
'quantity': e.messages,
|
||||||
'serial_numbers': e.messages,
|
'serial_numbers': e.messages,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Finally, save the item (with user information)
|
if serials is not None:
|
||||||
|
"""
|
||||||
|
If the stock item is going to be serialized, set the quantity to 1
|
||||||
|
"""
|
||||||
|
data['quantity'] = 1
|
||||||
|
|
||||||
|
# De-serialize the provided data
|
||||||
|
serializer = self.get_serializer(data=data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
|
||||||
|
# Create an initial StockItem object
|
||||||
|
item = serializer.save()
|
||||||
|
|
||||||
|
if serials:
|
||||||
|
# Assign the first serial number to the "master" item
|
||||||
|
item.serial = serials[0]
|
||||||
|
|
||||||
|
# Save the item (with user information)
|
||||||
item.save(user=user)
|
item.save(user=user)
|
||||||
|
|
||||||
if serials:
|
if serials:
|
||||||
"""
|
for serial in serials[1:]:
|
||||||
Serialize the stock, if required
|
|
||||||
|
|
||||||
- Note that the "original" stock item needs to be created first, so it can be serialized
|
# Create a duplicate stock item with the next serial number
|
||||||
- It is then immediately deleted
|
item.pk = None
|
||||||
"""
|
item.serial = serial
|
||||||
|
|
||||||
try:
|
item.save(user=user)
|
||||||
item.serializeStock(
|
|
||||||
quantity,
|
|
||||||
serials,
|
|
||||||
user,
|
|
||||||
notes=notes,
|
|
||||||
location=item.location,
|
|
||||||
)
|
|
||||||
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
|
||||||
|
|
||||||
# Delete the original item
|
|
||||||
item.delete()
|
|
||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
'quantity': quantity,
|
'quantity': quantity,
|
||||||
'serial_numbers': serials,
|
'serial_numbers': serials,
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response(response_data, status=status.HTTP_201_CREATED, headers=headers)
|
else:
|
||||||
|
response_data = serializer.data
|
||||||
|
|
||||||
except DjangoValidationError as e:
|
return Response(response_data, status=status.HTTP_201_CREATED, headers=self.get_success_headers(serializer.data))
|
||||||
raise ValidationError({
|
|
||||||
'quantity': e.messages,
|
|
||||||
'serial_numbers': e.messages,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Return a response
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ def extract_purchase_price(apps, schema_editor):
|
|||||||
if lines.exists():
|
if lines.exists():
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if line.purchase_price is not None:
|
if getattr(line, 'purchase_price', None) is not None:
|
||||||
|
|
||||||
# Copy pricing information across
|
# Copy pricing information across
|
||||||
item.purchase_price = line.purchase_price
|
item.purchase_price = line.purchase_price
|
||||||
|
|||||||
@@ -788,7 +788,12 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
query = self.allocations.aggregate(q=Coalesce(Sum('quantity'), Decimal(0)))
|
query = self.allocations.aggregate(q=Coalesce(Sum('quantity'), Decimal(0)))
|
||||||
|
|
||||||
return query['q']
|
total = query['q']
|
||||||
|
|
||||||
|
if total is None:
|
||||||
|
total = Decimal(0)
|
||||||
|
|
||||||
|
return total
|
||||||
|
|
||||||
def sales_order_allocation_count(self):
|
def sales_order_allocation_count(self):
|
||||||
"""
|
"""
|
||||||
@@ -797,14 +802,22 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
query = self.sales_order_allocations.aggregate(q=Coalesce(Sum('quantity'), Decimal(0)))
|
query = self.sales_order_allocations.aggregate(q=Coalesce(Sum('quantity'), Decimal(0)))
|
||||||
|
|
||||||
return query['q']
|
total = query['q']
|
||||||
|
|
||||||
|
if total is None:
|
||||||
|
total = Decimal(0)
|
||||||
|
|
||||||
|
return total
|
||||||
|
|
||||||
def allocation_count(self):
|
def allocation_count(self):
|
||||||
"""
|
"""
|
||||||
Return the total quantity allocated to builds or orders
|
Return the total quantity allocated to builds or orders
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self.build_allocation_count() + self.sales_order_allocation_count()
|
bo = self.build_allocation_count()
|
||||||
|
so = self.sales_order_allocation_count()
|
||||||
|
|
||||||
|
return bo + so
|
||||||
|
|
||||||
def unallocated_quantity(self):
|
def unallocated_quantity(self):
|
||||||
"""
|
"""
|
||||||
@@ -1022,7 +1035,7 @@ class StockItem(MPTTModel):
|
|||||||
def has_tracking_info(self):
|
def has_tracking_info(self):
|
||||||
return self.tracking_info_count > 0
|
return self.tracking_info_count > 0
|
||||||
|
|
||||||
def add_tracking_entry(self, entry_type, user, deltas={}, notes='', **kwargs):
|
def add_tracking_entry(self, entry_type, user, deltas=None, notes='', **kwargs):
|
||||||
"""
|
"""
|
||||||
Add a history tracking entry for this StockItem
|
Add a history tracking entry for this StockItem
|
||||||
|
|
||||||
@@ -1033,6 +1046,8 @@ class StockItem(MPTTModel):
|
|||||||
notes - User notes associated with this tracking entry
|
notes - User notes associated with this tracking entry
|
||||||
url - Optional URL associated with this tracking entry
|
url - Optional URL associated with this tracking entry
|
||||||
"""
|
"""
|
||||||
|
if deltas is None:
|
||||||
|
deltas = {}
|
||||||
|
|
||||||
# Has a location been specified?
|
# Has a location been specified?
|
||||||
location = kwargs.get('location', None)
|
location = kwargs.get('location', None)
|
||||||
|
|||||||
@@ -43,9 +43,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class='panel panel-hidden' id='panel-allocations'>
|
||||||
|
<div class='panel-heading'>
|
||||||
|
<h4>{% trans "Stock Item Allocations" %}</h4>
|
||||||
|
{% include "spacer.html" %}
|
||||||
|
</div>
|
||||||
|
<div class='panel-content'>
|
||||||
|
<div id='allocations-button-toolbar'>
|
||||||
|
<div class='btn-group' role='group'>
|
||||||
|
{% include "filter_list.html" with id="allocations" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class='table table-striped table-condensed' data-toolbar='#allocatoins-button-toolbar' id='stock-allocation-table'></table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class='panel panel-hidden' id='panel-children'>
|
<div class='panel panel-hidden' id='panel-children'>
|
||||||
<div class='panel-heading'>
|
<div class='panel-heading'>
|
||||||
<h4>{% trans "Child Stock Items" %}</h4>
|
<h4>{% trans "Child Stock Items" %}</h4>
|
||||||
|
{% include "spacer.html" %}
|
||||||
</div>
|
</div>
|
||||||
<div class='panel-content'>
|
<div class='panel-content'>
|
||||||
{% if item.child_count > 0 %}
|
{% if item.child_count > 0 %}
|
||||||
@@ -151,6 +168,19 @@
|
|||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
|
// Load the "allocations" tab
|
||||||
|
onPanelLoad('allocations', function() {
|
||||||
|
|
||||||
|
loadStockAllocationTable(
|
||||||
|
$("#stock-allocation-table"),
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
stock_item: {{ item.pk }},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
$('#stock-item-install').click(function() {
|
$('#stock-item-install').click(function() {
|
||||||
|
|
||||||
launchModalForm(
|
launchModalForm(
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
|
|
||||||
{% trans "Stock Tracking" as text %}
|
{% trans "Stock Tracking" as text %}
|
||||||
{% include "sidebar_item.html" with label='history' text=text icon="fa-history" %}
|
{% include "sidebar_item.html" with label='history' text=text icon="fa-history" %}
|
||||||
|
{% if item.part.salable or item.part.component %}
|
||||||
|
{% trans "Allocations" as text %}
|
||||||
|
{% include "sidebar_item.html" with label="allocations" text=text icon="fa-bookmark" %}
|
||||||
|
{% endif %}
|
||||||
{% if item.part.trackable %}
|
{% if item.part.trackable %}
|
||||||
{% trans "Test Data" as text %}
|
{% trans "Test Data" as text %}
|
||||||
{% include "sidebar_item.html" with label='test-data' text=text icon="fa-vial" %}
|
{% include "sidebar_item.html" with label='test-data' text=text icon="fa-vial" %}
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ class StockItemTest(StockAPITestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertContains(response, 'This field is required', status_code=status.HTTP_400_BAD_REQUEST)
|
self.assertContains(response, 'Valid part must be supplied', status_code=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# POST with an invalid part reference
|
# POST with an invalid part reference
|
||||||
|
|
||||||
@@ -355,7 +355,7 @@ class StockItemTest(StockAPITestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertContains(response, 'does not exist', status_code=status.HTTP_400_BAD_REQUEST)
|
self.assertContains(response, 'Valid part must be supplied', status_code=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# POST without quantity
|
# POST without quantity
|
||||||
response = self.post(
|
response = self.post(
|
||||||
@@ -380,6 +380,67 @@ class StockItemTest(StockAPITestCase):
|
|||||||
expected_code=201
|
expected_code=201
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_creation_with_serials(self):
|
||||||
|
"""
|
||||||
|
Test that serialized stock items can be created via the API,
|
||||||
|
"""
|
||||||
|
|
||||||
|
trackable_part = part.models.Part.objects.create(
|
||||||
|
name='My part',
|
||||||
|
description='A trackable part',
|
||||||
|
trackable=True,
|
||||||
|
default_location=StockLocation.objects.get(pk=1),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(trackable_part.stock_entries().count(), 0)
|
||||||
|
self.assertEqual(trackable_part.get_stock_count(), 0)
|
||||||
|
|
||||||
|
# This should fail, incorrect serial number count
|
||||||
|
response = self.post(
|
||||||
|
self.list_url,
|
||||||
|
data={
|
||||||
|
'part': trackable_part.pk,
|
||||||
|
'quantity': 10,
|
||||||
|
'serial_numbers': '1-20',
|
||||||
|
},
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.post(
|
||||||
|
self.list_url,
|
||||||
|
data={
|
||||||
|
'part': trackable_part.pk,
|
||||||
|
'quantity': 10,
|
||||||
|
'serial_numbers': '1-10',
|
||||||
|
},
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.data
|
||||||
|
|
||||||
|
self.assertEqual(data['quantity'], 10)
|
||||||
|
sn = data['serial_numbers']
|
||||||
|
|
||||||
|
# Check that each serial number was created
|
||||||
|
for i in range(1, 11):
|
||||||
|
self.assertTrue(i in sn)
|
||||||
|
|
||||||
|
# Check the unique stock item has been created
|
||||||
|
|
||||||
|
item = StockItem.objects.get(
|
||||||
|
part=trackable_part,
|
||||||
|
serial=str(i),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Item location should have been set automatically
|
||||||
|
self.assertIsNotNone(item.location)
|
||||||
|
|
||||||
|
self.assertEqual(str(i), item.serial)
|
||||||
|
|
||||||
|
# There now should be 10 unique stock entries for this part
|
||||||
|
self.assertEqual(trackable_part.stock_entries().count(), 10)
|
||||||
|
self.assertEqual(trackable_part.get_stock_count(), 10)
|
||||||
|
|
||||||
def test_default_expiry(self):
|
def test_default_expiry(self):
|
||||||
"""
|
"""
|
||||||
Test that the "default_expiry" functionality works via the API.
|
Test that the "default_expiry" functionality works via the API.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
|
{% 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_ALLOW_EDIT_IPN" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_NAME_FORMAT" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_NAME_FORMAT" %}
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_HISTORY" icon="fa-history" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_FORMS" icon="fa-dollar-sign" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_FORMS" icon="fa-dollar-sign" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_BOM" icon="fa-dollar-sign" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_BOM" icon="fa-dollar-sign" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_RELATED" icon="fa-random" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_RELATED" icon="fa-random" %}
|
||||||
|
|||||||
@@ -16,10 +16,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td><strong>{{ setting.name }}</strong></td>
|
<td><strong>{{ setting.name }}</strong></td>
|
||||||
|
<td>
|
||||||
|
{{ setting.description }}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if setting.is_bool %}
|
{% if setting.is_bool %}
|
||||||
<div class='form-check form-switch'>
|
<div class='form-check form-switch'>
|
||||||
<input class='form-check-input' fieldname='{{ setting.key.upper }}' id='setting-value-{{ setting.key.upper }}' type='checkbox' disabled='' {% if setting.as_bool %}checked=''{% endif %}>
|
<input class='form-check-input boolean-setting' fieldname='{{ setting.key.upper }}' pk='{{ setting.pk }}' setting='{{ setting.key.upper }}' id='setting-value-{{ setting.key.upper }}' type='checkbox' {% if setting.as_bool %}checked=''{% endif %} {% if plugin %}plugin='{{ plugin.pk }}'{% endif %}{% if user_setting %}user='{{request.user.id}}'{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div id='setting-{{ setting.pk }}'>
|
<div id='setting-{{ setting.pk }}'>
|
||||||
@@ -31,16 +34,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{{ setting.units }}
|
{{ setting.units }}
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<td>
|
|
||||||
{{ setting.description }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class='btn-group float-right'>
|
<div class='btn-group float-right'>
|
||||||
<button class='btn btn-outline-secondary btn-small btn-edit-setting' pk='{{ setting.pk }}' setting='{{ setting.key.upper }}' title='{% trans "Edit setting" %}' {% if plugin %}plugin='{{ plugin.pk }}'{% endif %}{% if user_setting %}user='{{request.user.id}}'{% endif %}>
|
<button class='btn btn-outline-secondary btn-small btn-edit-setting' pk='{{ setting.pk }}' setting='{{ setting.key.upper }}' title='{% trans "Edit setting" %}' {% if plugin %}plugin='{{ plugin.pk }}'{% endif %}{% if user_setting %}user='{{request.user.id}}'{% endif %}>
|
||||||
<span class='fas fa-edit icon-green'></span>
|
<span class='fas fa-edit icon-green'></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -62,6 +62,43 @@
|
|||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
|
// Callback for when boolean settings are edited
|
||||||
|
$('table').find('.boolean-setting').change(function() {
|
||||||
|
|
||||||
|
var setting = $(this).attr('setting');
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
var plugin = $(this).attr('plugin');
|
||||||
|
var user = $(this).attr('user');
|
||||||
|
|
||||||
|
var checked = this.checked;
|
||||||
|
|
||||||
|
// Global setting by default
|
||||||
|
var url = `/api/settings/global/${pk}/`;
|
||||||
|
|
||||||
|
if (plugin) {
|
||||||
|
url = `/api/plugin/settings/${pk}/`;
|
||||||
|
} else if (user) {
|
||||||
|
url = `/api/settings/user/${pk}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
inventreePut(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
value: checked.toString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
onSuccess: function(data) {
|
||||||
|
},
|
||||||
|
error: function(xhr) {
|
||||||
|
showApiError(xhr, url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback for when non-boolean settings are edited
|
||||||
$('table').find('.btn-edit-setting').click(function() {
|
$('table').find('.btn-edit-setting').click(function() {
|
||||||
var setting = $(this).attr('setting');
|
var setting = $(this).attr('setting');
|
||||||
var pk = $(this).attr('pk');
|
var pk = $(this).attr('pk');
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
{% load plugin_extras %}
|
{% load plugin_extras %}
|
||||||
|
|
||||||
{% trans "User Settings" as text %}
|
{% trans "User Settings" as text %}
|
||||||
{% include "sidebar_header.html" with text=text icon='fa-user' %}
|
{% include "sidebar_header.html" with text=text icon='fa-user-cog' %}
|
||||||
|
|
||||||
{% trans "Account Settings" as text %}
|
{% trans "Account Settings" as text %}
|
||||||
{% include "sidebar_item.html" with label='account' text=text icon="fa-cog" %}
|
{% include "sidebar_item.html" with label='account' text=text icon="fa-sign-in-alt" %}
|
||||||
{% trans "Display Settings" as text %}
|
{% trans "Display Settings" as text %}
|
||||||
{% include "sidebar_item.html" with label='user-display' text=text icon="fa-desktop" %}
|
{% include "sidebar_item.html" with label='user-display' text=text icon="fa-desktop" %}
|
||||||
{% trans "Home Page" as text %}
|
{% trans "Home Page" as text %}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* exported
|
/* exported
|
||||||
|
constructBomUploadTable,
|
||||||
downloadBomTemplate,
|
downloadBomTemplate,
|
||||||
exportBom,
|
exportBom,
|
||||||
newPartFromBomWizard,
|
newPartFromBomWizard,
|
||||||
@@ -22,8 +23,221 @@
|
|||||||
loadUsedInTable,
|
loadUsedInTable,
|
||||||
removeRowFromBomWizard,
|
removeRowFromBomWizard,
|
||||||
removeColFromBomWizard,
|
removeColFromBomWizard,
|
||||||
|
submitBomTable
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/* Construct a table of data extracted from a BOM file.
|
||||||
|
* This data is used to import a BOM interactively.
|
||||||
|
*/
|
||||||
|
function constructBomUploadTable(data, options={}) {
|
||||||
|
|
||||||
|
if (!data.rows) {
|
||||||
|
// TODO: Error message!
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function constructRow(row, idx, fields) {
|
||||||
|
// Construct an individual row from the provided data
|
||||||
|
|
||||||
|
var errors = {};
|
||||||
|
|
||||||
|
if (data.errors && data.errors.length > idx) {
|
||||||
|
errors = data.errors[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
var field_options = {
|
||||||
|
hideLabels: true,
|
||||||
|
hideClearButton: true,
|
||||||
|
form_classes: 'bom-form-group',
|
||||||
|
};
|
||||||
|
|
||||||
|
function constructRowField(field_name) {
|
||||||
|
|
||||||
|
var field = fields[field_name] || null;
|
||||||
|
|
||||||
|
if (!field) {
|
||||||
|
return `Cannot render field '${field_name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
field.value = row[field_name];
|
||||||
|
|
||||||
|
return constructField(`items_${field_name}_${idx}`, field, field_options);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct form inputs
|
||||||
|
var sub_part = constructRowField('sub_part');
|
||||||
|
var quantity = constructRowField('quantity');
|
||||||
|
var reference = constructRowField('reference');
|
||||||
|
var overage = constructRowField('overage');
|
||||||
|
var variants = constructRowField('allow_variants');
|
||||||
|
var inherited = constructRowField('inherited');
|
||||||
|
var optional = constructRowField('optional');
|
||||||
|
var note = constructRowField('note');
|
||||||
|
|
||||||
|
var buttons = `<div class='btn-group float-right' role='group'>`;
|
||||||
|
|
||||||
|
buttons += makeIconButton('fa-info-circle', 'button-row-data', idx, '{% trans "Display row data" %}');
|
||||||
|
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', idx, '{% trans "Remove row" %}');
|
||||||
|
|
||||||
|
buttons += `</div>`;
|
||||||
|
|
||||||
|
var html = `
|
||||||
|
<tr id='items_${idx}' class='bom-import-row' idx='${idx}'>
|
||||||
|
<td id='col_sub_part_${idx}'>${sub_part}</td>
|
||||||
|
<td id='col_quantity_${idx}'>${quantity}</td>
|
||||||
|
<td id='col_reference_${idx}'>${reference}</td>
|
||||||
|
<td id='col_overage_${idx}'>${overage}</td>
|
||||||
|
<td id='col_variants_${idx}'>${variants}</td>
|
||||||
|
<td id='col_inherited_${idx}'>${inherited}</td>
|
||||||
|
<td id='col_optional_${idx}'>${optional}</td>
|
||||||
|
<td id='col_note_${idx}'>${note}</td>
|
||||||
|
<td id='col_buttons_${idx}'>${buttons}</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
$('#bom-import-table tbody').append(html);
|
||||||
|
|
||||||
|
// Handle any errors raised by initial data import
|
||||||
|
if (errors.part) {
|
||||||
|
addFieldErrorMessage(`items_sub_part_${idx}`, errors.part);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.quantity) {
|
||||||
|
addFieldErrorMessage(`items_quantity_${idx}`, errors.quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the "part" selector for this row
|
||||||
|
initializeRelatedField(
|
||||||
|
{
|
||||||
|
name: `items_sub_part_${idx}`,
|
||||||
|
value: row.part,
|
||||||
|
api_url: '{% url "api-part-list" %}',
|
||||||
|
filters: {
|
||||||
|
component: true,
|
||||||
|
},
|
||||||
|
model: 'part',
|
||||||
|
required: true,
|
||||||
|
auto_fill: false,
|
||||||
|
onSelect: function(data, field, opts) {
|
||||||
|
// TODO?
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add callback for "remove row" button
|
||||||
|
$(`#button-row-remove-${idx}`).click(function() {
|
||||||
|
$(`#items_${idx}`).remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add callback for "show data" button
|
||||||
|
$(`#button-row-data-${idx}`).click(function() {
|
||||||
|
|
||||||
|
var modal = createNewModal({
|
||||||
|
title: '{% trans "Row Data" %}',
|
||||||
|
cancelText: '{% trans "Close" %}',
|
||||||
|
hideSubmitButton: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prettify the original import data
|
||||||
|
var pretty = JSON.stringify(row, undefined, 4);
|
||||||
|
|
||||||
|
var html = `
|
||||||
|
<div class='alert alert-block'>
|
||||||
|
<pre><code>${pretty}</code></pre>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
modalSetContent(modal, html);
|
||||||
|
|
||||||
|
$(modal).modal('show');
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request API endpoint options
|
||||||
|
getApiEndpointOptions('{% url "api-bom-list" %}', function(response) {
|
||||||
|
|
||||||
|
var fields = response.actions.POST;
|
||||||
|
|
||||||
|
data.rows.forEach(function(row, idx) {
|
||||||
|
constructRow(row, idx, fields);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Extract rows from the BOM upload table,
|
||||||
|
* and submit data to the server
|
||||||
|
*/
|
||||||
|
function submitBomTable(part_id, options={}) {
|
||||||
|
|
||||||
|
// Extract rows from the form
|
||||||
|
var rows = [];
|
||||||
|
|
||||||
|
var idx_values = [];
|
||||||
|
|
||||||
|
var url = '{% url "api-bom-upload" %}';
|
||||||
|
|
||||||
|
$('.bom-import-row').each(function() {
|
||||||
|
var idx = $(this).attr('idx');
|
||||||
|
|
||||||
|
idx_values.push(idx);
|
||||||
|
|
||||||
|
// Extract each field from the row
|
||||||
|
rows.push({
|
||||||
|
part: part_id,
|
||||||
|
sub_part: getFormFieldValue(`items_sub_part_${idx}`, {}),
|
||||||
|
quantity: getFormFieldValue(`items_quantity_${idx}`, {}),
|
||||||
|
reference: getFormFieldValue(`items_reference_${idx}`, {}),
|
||||||
|
overage: getFormFieldValue(`items_overage_${idx}`, {}),
|
||||||
|
allow_variants: getFormFieldValue(`items_allow_variants_${idx}`, {type: 'boolean'}),
|
||||||
|
inherited: getFormFieldValue(`items_inherited_${idx}`, {type: 'boolean'}),
|
||||||
|
optional: getFormFieldValue(`items_optional_${idx}`, {type: 'boolean'}),
|
||||||
|
note: getFormFieldValue(`items_note_${idx}`, {}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var data = {
|
||||||
|
items: rows,
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = {
|
||||||
|
nested: {
|
||||||
|
items: idx_values,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getApiEndpointOptions(url, function(response) {
|
||||||
|
var fields = response.actions.POST;
|
||||||
|
|
||||||
|
// Disable the "Submit BOM" button
|
||||||
|
$('#bom-submit').prop('disabled', true);
|
||||||
|
$('#bom-submit-icon').show();
|
||||||
|
|
||||||
|
inventreePut(url, data, {
|
||||||
|
method: 'POST',
|
||||||
|
success: function(response) {
|
||||||
|
window.location.href = `/part/${part_id}/?display=bom`;
|
||||||
|
},
|
||||||
|
error: function(xhr) {
|
||||||
|
switch (xhr.status) {
|
||||||
|
case 400:
|
||||||
|
handleFormErrors(xhr.responseJSON, fields, options);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
showApiError(xhr, url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enable the submit button
|
||||||
|
$('#bom-submit').prop('disabled', false);
|
||||||
|
$('#bom-submit-icon').hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function downloadBomTemplate(options={}) {
|
function downloadBomTemplate(options={}) {
|
||||||
|
|
||||||
var format = options.format;
|
var format = options.format;
|
||||||
@@ -77,7 +291,7 @@ function exportBom(part_id, options={}) {
|
|||||||
value: inventreeLoad('bom-export-format', 'csv'),
|
value: inventreeLoad('bom-export-format', 'csv'),
|
||||||
choices: exportFormatOptions(),
|
choices: exportFormatOptions(),
|
||||||
},
|
},
|
||||||
cascading: {
|
cascade: {
|
||||||
label: '{% trans "Cascading" %}',
|
label: '{% trans "Cascading" %}',
|
||||||
help_text: '{% trans "Download cascading / multi-level BOM" %}',
|
help_text: '{% trans "Download cascading / multi-level BOM" %}',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
@@ -118,7 +332,7 @@ function exportBom(part_id, options={}) {
|
|||||||
onSubmit: function(fields, opts) {
|
onSubmit: function(fields, opts) {
|
||||||
|
|
||||||
// Extract values from the form
|
// Extract values from the form
|
||||||
var field_names = ['format', 'cascading', 'levels', 'parameter_data', 'stock_data', 'manufacturer_data', 'supplier_data'];
|
var field_names = ['format', 'cascade', 'levels', 'parameter_data', 'stock_data', 'manufacturer_data', 'supplier_data'];
|
||||||
|
|
||||||
var url = `/part/${part_id}/bom-download/?`;
|
var url = `/part/${part_id}/bom-download/?`;
|
||||||
|
|
||||||
@@ -319,7 +533,19 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
|
|||||||
rows += renderSubstituteRow(sub);
|
rows += renderSubstituteRow(sub);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var part_thumb = thumbnailImage(options.sub_part_detail.thumbnail || options.sub_part_detail.image);
|
||||||
|
var part_name = options.sub_part_detail.full_name;
|
||||||
|
var part_desc = options.sub_part_detail.description;
|
||||||
|
|
||||||
var html = `
|
var html = `
|
||||||
|
<div class='alert alert-block'>
|
||||||
|
<strong>{% trans "Base Part" %}</strong><hr>
|
||||||
|
${part_thumb} ${part_name} - <em>${part_desc}</em>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add a table of individual rows
|
||||||
|
html += `
|
||||||
<table class='table table-striped table-condensed' id='substitute-table'>
|
<table class='table table-striped table-condensed' id='substitute-table'>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -337,7 +563,7 @@ function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
|
|||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class='alert alert-success alert-block'>
|
<div class='alert alert-success alert-block'>
|
||||||
{% trans "Select and add a new variant item using the input below" %}
|
{% trans "Select and add a new substitute part using the input below" %}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -766,6 +992,11 @@ function loadBomTable(table, options={}) {
|
|||||||
// This function may be called recursively for multi-level BOMs
|
// This function may be called recursively for multi-level BOMs
|
||||||
function requestSubItems(bom_pk, part_pk) {
|
function requestSubItems(bom_pk, part_pk) {
|
||||||
|
|
||||||
|
// TODO: 2022-02-03 Currently, multi-level BOMs are not actually displayed.
|
||||||
|
|
||||||
|
// Re-enable this function once multi-level display has been re-deployed
|
||||||
|
return;
|
||||||
|
|
||||||
inventreeGet(
|
inventreeGet(
|
||||||
options.bom_url,
|
options.bom_url,
|
||||||
{
|
{
|
||||||
@@ -945,7 +1176,9 @@ function loadBomTable(table, options={}) {
|
|||||||
subs,
|
subs,
|
||||||
{
|
{
|
||||||
table: table,
|
table: table,
|
||||||
|
part: row.part,
|
||||||
sub_part: row.sub_part,
|
sub_part: row.sub_part,
|
||||||
|
sub_part_detail: row.sub_part_detail,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -417,6 +417,145 @@ function completeBuildOutputs(build_id, outputs, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch a modal form to delete selected build outputs
|
||||||
|
*/
|
||||||
|
function deleteBuildOutputs(build_id, outputs, options={}) {
|
||||||
|
|
||||||
|
if (outputs.length == 0) {
|
||||||
|
showAlertDialog(
|
||||||
|
'{% trans "Select Build Outputs" %}',
|
||||||
|
'{% trans "At least one build output must be selected" %}',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a single build output (StockItem)
|
||||||
|
function renderBuildOutput(output, opts={}) {
|
||||||
|
var pk = output.pk;
|
||||||
|
|
||||||
|
var output_html = imageHoverIcon(output.part_detail.thumbnail);
|
||||||
|
|
||||||
|
if (output.quantity == 1 && output.serial) {
|
||||||
|
output_html += `{% trans "Serial Number" %}: ${output.serial}`;
|
||||||
|
} else {
|
||||||
|
output_html += `{% trans "Quantity" %}: ${output.quantity}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
var buttons = `<div class='btn-group float-right' role='group'>`;
|
||||||
|
|
||||||
|
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove row" %}');
|
||||||
|
|
||||||
|
buttons += '</div>';
|
||||||
|
|
||||||
|
var field = constructField(
|
||||||
|
`outputs_output_${pk}`,
|
||||||
|
{
|
||||||
|
type: 'raw',
|
||||||
|
html: output_html,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hideLabels: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
var html = `
|
||||||
|
<tr id='output_row_${pk}'>
|
||||||
|
<td>${field}</td>
|
||||||
|
<td>${output.part_detail.full_name}</td>
|
||||||
|
<td>${buttons}</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct table entries
|
||||||
|
var table_entries = '';
|
||||||
|
|
||||||
|
outputs.forEach(function(output) {
|
||||||
|
table_entries += renderBuildOutput(output);
|
||||||
|
});
|
||||||
|
|
||||||
|
var html = `
|
||||||
|
<table class='table table-striped table-condensed' id='build-complete-table'>
|
||||||
|
<thead>
|
||||||
|
<th colspan='2'>{% trans "Output" %}</th>
|
||||||
|
<th><!-- Actions --></th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${table_entries}
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
constructForm(`/api/build/${build_id}/delete-outputs/`, {
|
||||||
|
method: 'POST',
|
||||||
|
preFormContent: html,
|
||||||
|
fields: {},
|
||||||
|
confirm: true,
|
||||||
|
title: '{% trans "Delete Build Outputs" %}',
|
||||||
|
afterRender: function(fields, opts) {
|
||||||
|
// Setup callbacks to remove outputs
|
||||||
|
$(opts.modal).find('.button-row-remove').click(function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
$(opts.modal).find(`#output_row_${pk}`).remove();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmit: function(fields, opts) {
|
||||||
|
var data = {
|
||||||
|
outputs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
var output_pk_values = [];
|
||||||
|
|
||||||
|
outputs.forEach(function(output) {
|
||||||
|
var pk = output.pk;
|
||||||
|
|
||||||
|
var row = $(opts.modal).find(`#output_row_${pk}`);
|
||||||
|
|
||||||
|
if (row.exists()) {
|
||||||
|
data.outputs.push({
|
||||||
|
output: pk
|
||||||
|
});
|
||||||
|
output_pk_values.push(pk);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
opts.nested = {
|
||||||
|
'outputs': output_pk_values,
|
||||||
|
};
|
||||||
|
|
||||||
|
inventreePut(
|
||||||
|
opts.url,
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
success: function(response) {
|
||||||
|
$(opts.modal).modal('hide');
|
||||||
|
|
||||||
|
if (options.success) {
|
||||||
|
options.success(response);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(xhr) {
|
||||||
|
switch (xhr.status) {
|
||||||
|
case 400:
|
||||||
|
handleFormErrors(xhr.responseJSON, fields, opts);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$(opts.modal).modal('hide');
|
||||||
|
showApiError(xhr, opts.url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a table showing all the BuildOrder allocations for a given part
|
* Load a table showing all the BuildOrder allocations for a given part
|
||||||
*/
|
*/
|
||||||
@@ -594,6 +733,7 @@ function loadBuildOutputTable(build_info, options={}) {
|
|||||||
{
|
{
|
||||||
success: function() {
|
success: function() {
|
||||||
$(table).bootstrapTable('refresh');
|
$(table).bootstrapTable('refresh');
|
||||||
|
$('#build-stock-table').bootstrapTable('refresh');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -603,15 +743,17 @@ function loadBuildOutputTable(build_info, options={}) {
|
|||||||
$(table).find('.button-output-delete').click(function() {
|
$(table).find('.button-output-delete').click(function() {
|
||||||
var pk = $(this).attr('pk');
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
// TODO: Move this to the API
|
var output = $(table).bootstrapTable('getRowByUniqueId', pk);
|
||||||
launchModalForm(
|
|
||||||
`/build/${build_info.pk}/delete-output/`,
|
deleteBuildOutputs(
|
||||||
|
build_info.pk,
|
||||||
|
[
|
||||||
|
output,
|
||||||
|
],
|
||||||
{
|
{
|
||||||
data: {
|
success: function() {
|
||||||
output: pk
|
|
||||||
},
|
|
||||||
onSuccess: function() {
|
|
||||||
$(table).bootstrapTable('refresh');
|
$(table).bootstrapTable('refresh');
|
||||||
|
$('#build-stock-table').bootstrapTable('refresh');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -835,7 +835,17 @@ function updateFieldValue(name, value, field, options) {
|
|||||||
// Find the named field element in the modal DOM
|
// Find the named field element in the modal DOM
|
||||||
function getFormFieldElement(name, options) {
|
function getFormFieldElement(name, options) {
|
||||||
|
|
||||||
var el = $(options.modal).find(`#id_${name}`);
|
var field_name = getFieldName(name, options);
|
||||||
|
|
||||||
|
var el = null;
|
||||||
|
|
||||||
|
if (options && options.modal) {
|
||||||
|
// Field element is associated with a model?
|
||||||
|
el = $(options.modal).find(`#id_${field_name}`);
|
||||||
|
} else {
|
||||||
|
// Field element is top-level
|
||||||
|
el = $(`#id_${field_name}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!el.exists) {
|
if (!el.exists) {
|
||||||
console.log(`ERROR: Could not find form element for field '${name}'`);
|
console.log(`ERROR: Could not find form element for field '${name}'`);
|
||||||
@@ -880,12 +890,13 @@ function validateFormField(name, options) {
|
|||||||
* - field: The field specification provided from the OPTIONS request
|
* - field: The field specification provided from the OPTIONS request
|
||||||
* - options: The original options object provided by the client
|
* - options: The original options object provided by the client
|
||||||
*/
|
*/
|
||||||
function getFormFieldValue(name, field, options) {
|
function getFormFieldValue(name, field={}, options={}) {
|
||||||
|
|
||||||
// Find the HTML element
|
// Find the HTML element
|
||||||
var el = getFormFieldElement(name, options);
|
var el = getFormFieldElement(name, options);
|
||||||
|
|
||||||
if (!el) {
|
if (!el) {
|
||||||
|
console.log(`ERROR: getFormFieldValue could not locate field '{name}'`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -971,8 +982,9 @@ function handleFormSuccess(response, options) {
|
|||||||
/*
|
/*
|
||||||
* Remove all error text items from the form
|
* Remove all error text items from the form
|
||||||
*/
|
*/
|
||||||
function clearFormErrors(options) {
|
function clearFormErrors(options={}) {
|
||||||
|
|
||||||
|
if (options && options.modal) {
|
||||||
// Remove the individual error messages
|
// Remove the individual error messages
|
||||||
$(options.modal).find('.form-error-message').remove();
|
$(options.modal).find('.form-error-message').remove();
|
||||||
|
|
||||||
@@ -981,6 +993,11 @@ function clearFormErrors(options) {
|
|||||||
|
|
||||||
// Hide the 'non field errors'
|
// Hide the 'non field errors'
|
||||||
$(options.modal).find('#non-field-errors').html('');
|
$(options.modal).find('#non-field-errors').html('');
|
||||||
|
} else {
|
||||||
|
$('.form-error-message').remove();
|
||||||
|
$('.form-field-errors').removeClass('form-field-error');
|
||||||
|
$('#non-field-errors').html('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -1008,7 +1025,7 @@ function clearFormErrors(options) {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function handleNestedErrors(errors, field_name, options) {
|
function handleNestedErrors(errors, field_name, options={}) {
|
||||||
|
|
||||||
var error_list = errors[field_name];
|
var error_list = errors[field_name];
|
||||||
|
|
||||||
@@ -1039,8 +1056,31 @@ function handleNestedErrors(errors, field_name, options) {
|
|||||||
|
|
||||||
// Here, error_item is a map of field names to error messages
|
// Here, error_item is a map of field names to error messages
|
||||||
for (sub_field_name in error_item) {
|
for (sub_field_name in error_item) {
|
||||||
|
|
||||||
var errors = error_item[sub_field_name];
|
var errors = error_item[sub_field_name];
|
||||||
|
|
||||||
|
if (sub_field_name == 'non_field_errors') {
|
||||||
|
|
||||||
|
var row = null;
|
||||||
|
|
||||||
|
if (options.modal) {
|
||||||
|
row = $(options.modal).find(`#items_${nest_id}`);
|
||||||
|
} else {
|
||||||
|
row = $(`#items_${nest_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var ii = errors.length - 1; ii >= 0; ii--) {
|
||||||
|
|
||||||
|
var html = `
|
||||||
|
<div id='error_${ii}_non_field_error' class='help-block form-field-error form-error-message'>
|
||||||
|
<strong>${errors[ii]}</strong>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
row.after(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// Find the target (nested) field
|
// Find the target (nested) field
|
||||||
var target = `${field_name}_${sub_field_name}_${nest_id}`;
|
var target = `${field_name}_${sub_field_name}_${nest_id}`;
|
||||||
|
|
||||||
@@ -1064,15 +1104,23 @@ function handleNestedErrors(errors, field_name, options) {
|
|||||||
* - fields: The form data object
|
* - fields: The form data object
|
||||||
* - options: Form options provided by the client
|
* - options: Form options provided by the client
|
||||||
*/
|
*/
|
||||||
function handleFormErrors(errors, fields, options) {
|
function handleFormErrors(errors, fields={}, options={}) {
|
||||||
|
|
||||||
// Reset the status of the "submit" button
|
// Reset the status of the "submit" button
|
||||||
|
if (options.modal) {
|
||||||
$(options.modal).find('#modal-form-submit').prop('disabled', false);
|
$(options.modal).find('#modal-form-submit').prop('disabled', false);
|
||||||
|
}
|
||||||
|
|
||||||
// Remove any existing error messages from the form
|
// Remove any existing error messages from the form
|
||||||
clearFormErrors(options);
|
clearFormErrors(options);
|
||||||
|
|
||||||
var non_field_errors = $(options.modal).find('#non-field-errors');
|
var non_field_errors = null;
|
||||||
|
|
||||||
|
if (options.modal) {
|
||||||
|
non_field_errors = $(options.modal).find('#non-field-errors');
|
||||||
|
} else {
|
||||||
|
non_field_errors = $('#non-field-errors');
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Display the JSON error text when hovering over the "info" icon
|
// TODO: Display the JSON error text when hovering over the "info" icon
|
||||||
non_field_errors.append(
|
non_field_errors.append(
|
||||||
@@ -1148,14 +1196,21 @@ function handleFormErrors(errors, fields, options) {
|
|||||||
/*
|
/*
|
||||||
* Add a rendered error message to the provided field
|
* Add a rendered error message to the provided field
|
||||||
*/
|
*/
|
||||||
function addFieldErrorMessage(field_name, error_text, error_idx, options) {
|
function addFieldErrorMessage(name, error_text, error_idx=0, options={}) {
|
||||||
|
|
||||||
// Add the 'form-field-error' class
|
field_name = getFieldName(name, options);
|
||||||
|
|
||||||
|
var field_dom = null;
|
||||||
|
|
||||||
|
if (options && options.modal) {
|
||||||
$(options.modal).find(`#div_id_${field_name}`).addClass('form-field-error');
|
$(options.modal).find(`#div_id_${field_name}`).addClass('form-field-error');
|
||||||
|
field_dom = $(options.modal).find(`#errors-${field_name}`);
|
||||||
|
} else {
|
||||||
|
$(`#div_id_${field_name}`).addClass('form-field-error');
|
||||||
|
field_dom = $(`#errors-${field_name}`);
|
||||||
|
}
|
||||||
|
|
||||||
var field_dom = $(options.modal).find(`#errors-${field_name}`);
|
if (field_dom.exists()) {
|
||||||
|
|
||||||
if (field_dom) {
|
|
||||||
|
|
||||||
var error_html = `
|
var error_html = `
|
||||||
<span id='error_${error_idx}_id_${field_name}' class='help-block form-error-message'>
|
<span id='error_${error_idx}_id_${field_name}' class='help-block form-error-message'>
|
||||||
@@ -1224,9 +1279,17 @@ function addClearCallbacks(fields, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function addClearCallback(name, field, options) {
|
function addClearCallback(name, field, options={}) {
|
||||||
|
|
||||||
var el = $(options.modal).find(`#clear_${name}`);
|
var field_name = getFieldName(name, options);
|
||||||
|
|
||||||
|
var el = null;
|
||||||
|
|
||||||
|
if (options && options.modal) {
|
||||||
|
el = $(options.modal).find(`#clear_${field_name}`);
|
||||||
|
} else {
|
||||||
|
el = $(`#clear_${field_name}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!el) {
|
if (!el) {
|
||||||
console.log(`WARNING: addClearCallback could not find field '${name}'`);
|
console.log(`WARNING: addClearCallback could not find field '${name}'`);
|
||||||
@@ -1324,11 +1387,13 @@ function hideFormGroup(group, options) {
|
|||||||
$(options.modal).find(`#form-panel-${group}`).hide();
|
$(options.modal).find(`#form-panel-${group}`).hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Show a form group
|
// Show a form group
|
||||||
function showFormGroup(group, options) {
|
function showFormGroup(group, options) {
|
||||||
$(options.modal).find(`#form-panel-${group}`).show();
|
$(options.modal).find(`#form-panel-${group}`).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function setFormGroupVisibility(group, vis, options) {
|
function setFormGroupVisibility(group, vis, options) {
|
||||||
if (vis) {
|
if (vis) {
|
||||||
showFormGroup(group, options);
|
showFormGroup(group, options);
|
||||||
@@ -1338,7 +1403,7 @@ function setFormGroupVisibility(group, vis, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function initializeRelatedFields(fields, options) {
|
function initializeRelatedFields(fields, options={}) {
|
||||||
|
|
||||||
var field_names = options.field_names;
|
var field_names = options.field_names;
|
||||||
|
|
||||||
@@ -1374,21 +1439,23 @@ function initializeRelatedFields(fields, options) {
|
|||||||
*/
|
*/
|
||||||
function addSecondaryModal(field, fields, options) {
|
function addSecondaryModal(field, fields, options) {
|
||||||
|
|
||||||
var name = field.name;
|
var field_name = getFieldName(field.name, options);
|
||||||
|
|
||||||
var secondary = field.secondary;
|
var depth = options.depth || 0;
|
||||||
|
|
||||||
var html = `
|
var html = `
|
||||||
<span style='float: right;'>
|
<span style='float: right;'>
|
||||||
<div type='button' class='btn btn-primary btn-secondary btn-form-secondary' title='${secondary.title || secondary.label}' id='btn-new-${name}'>
|
<div type='button' class='btn btn-primary btn-secondary btn-form-secondary' title='${field.secondary.title || field.secondary.label}' id='btn-new-${field_name}'>
|
||||||
${secondary.label || secondary.title}
|
${field.secondary.label || field.secondary.title}
|
||||||
</div>
|
</div>
|
||||||
</span>`;
|
</span>`;
|
||||||
|
|
||||||
$(options.modal).find(`label[for="id_${name}"]`).append(html);
|
$(options.modal).find(`label[for="id_${field_name}"]`).append(html);
|
||||||
|
|
||||||
// Callback function when the secondary button is pressed
|
// Callback function when the secondary button is pressed
|
||||||
$(options.modal).find(`#btn-new-${name}`).click(function() {
|
$(options.modal).find(`#btn-new-${field_name}`).click(function() {
|
||||||
|
|
||||||
|
var secondary = field.secondary;
|
||||||
|
|
||||||
// Determine the API query URL
|
// Determine the API query URL
|
||||||
var url = secondary.api_url || field.api_url;
|
var url = secondary.api_url || field.api_url;
|
||||||
@@ -1409,16 +1476,24 @@ function addSecondaryModal(field, fields, options) {
|
|||||||
// Force refresh from the API, to get full detail
|
// Force refresh from the API, to get full detail
|
||||||
inventreeGet(`${url}${data.pk}/`, {}, {
|
inventreeGet(`${url}${data.pk}/`, {}, {
|
||||||
success: function(responseData) {
|
success: function(responseData) {
|
||||||
|
setRelatedFieldData(field.name, responseData, options);
|
||||||
setRelatedFieldData(name, responseData, options);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Relinquish keyboard focus for this modal
|
||||||
|
$(options.modal).modal({
|
||||||
|
keyboard: false,
|
||||||
|
});
|
||||||
|
|
||||||
// Method should be "POST" for creation
|
// Method should be "POST" for creation
|
||||||
secondary.method = secondary.method || 'POST';
|
secondary.method = secondary.method || 'POST';
|
||||||
|
|
||||||
|
secondary.modal = null;
|
||||||
|
|
||||||
|
secondary.depth = depth + 1;
|
||||||
|
|
||||||
constructForm(
|
constructForm(
|
||||||
url,
|
url,
|
||||||
secondary
|
secondary
|
||||||
@@ -1436,12 +1511,11 @@ function addSecondaryModal(field, fields, options) {
|
|||||||
* - field: Field definition from the OPTIONS request
|
* - field: Field definition from the OPTIONS request
|
||||||
* - options: Original options object provided by the client
|
* - options: Original options object provided by the client
|
||||||
*/
|
*/
|
||||||
function initializeRelatedField(field, fields, options) {
|
function initializeRelatedField(field, fields, options={}) {
|
||||||
|
|
||||||
var name = field.name;
|
var name = field.name;
|
||||||
|
|
||||||
if (!field.api_url) {
|
if (!field.api_url) {
|
||||||
// TODO: Provide manual api_url option?
|
|
||||||
console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`);
|
console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1459,10 +1533,22 @@ function initializeRelatedField(field, fields, options) {
|
|||||||
// limit size for AJAX requests
|
// limit size for AJAX requests
|
||||||
var pageSize = options.pageSize || 25;
|
var pageSize = options.pageSize || 25;
|
||||||
|
|
||||||
|
var parent = null;
|
||||||
|
var auto_width = false;
|
||||||
|
var width = '100%';
|
||||||
|
|
||||||
|
// Special considerations if the select2 input is a child of a modal
|
||||||
|
if (options && options.modal) {
|
||||||
|
parent = $(options.modal);
|
||||||
|
auto_width = true;
|
||||||
|
width = null;
|
||||||
|
}
|
||||||
|
|
||||||
select.select2({
|
select.select2({
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
dropdownParent: $(options.modal),
|
dropdownParent: parent,
|
||||||
dropdownAutoWidth: false,
|
dropdownAutoWidth: auto_width,
|
||||||
|
width: width,
|
||||||
language: {
|
language: {
|
||||||
noResults: function(query) {
|
noResults: function(query) {
|
||||||
if (field.noResults) {
|
if (field.noResults) {
|
||||||
@@ -1638,7 +1724,7 @@ function initializeRelatedField(field, fields, options) {
|
|||||||
* - data: JSON data representing the model instance
|
* - data: JSON data representing the model instance
|
||||||
* - options: The modal form specifications
|
* - options: The modal form specifications
|
||||||
*/
|
*/
|
||||||
function setRelatedFieldData(name, data, options) {
|
function setRelatedFieldData(name, data, options={}) {
|
||||||
|
|
||||||
var select = getFormFieldElement(name, options);
|
var select = getFormFieldElement(name, options);
|
||||||
|
|
||||||
@@ -1718,6 +1804,9 @@ function renderModelData(name, model, data, parameters, options) {
|
|||||||
case 'partparametertemplate':
|
case 'partparametertemplate':
|
||||||
renderer = renderPartParameterTemplate;
|
renderer = renderPartParameterTemplate;
|
||||||
break;
|
break;
|
||||||
|
case 'purchaseorder':
|
||||||
|
renderer = renderPurchaseOrder;
|
||||||
|
break;
|
||||||
case 'salesorder':
|
case 'salesorder':
|
||||||
renderer = renderSalesOrder;
|
renderer = renderSalesOrder;
|
||||||
break;
|
break;
|
||||||
@@ -1757,6 +1846,20 @@ function renderModelData(name, model, data, parameters, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Construct a field name for the given field
|
||||||
|
*/
|
||||||
|
function getFieldName(name, options={}) {
|
||||||
|
var field_name = name;
|
||||||
|
|
||||||
|
if (options && options.depth) {
|
||||||
|
field_name += `_${options.depth}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return field_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Construct a single form 'field' for rendering in a form.
|
* Construct a single form 'field' for rendering in a form.
|
||||||
*
|
*
|
||||||
@@ -1783,7 +1886,7 @@ function constructField(name, parameters, options) {
|
|||||||
return constructCandyInput(name, parameters, options);
|
return constructCandyInput(name, parameters, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
var field_name = `id_${name}`;
|
var field_name = getFieldName(name, options);
|
||||||
|
|
||||||
// Hidden inputs are rendered without label / help text / etc
|
// Hidden inputs are rendered without label / help text / etc
|
||||||
if (parameters.hidden) {
|
if (parameters.hidden) {
|
||||||
@@ -1803,6 +1906,8 @@ function constructField(name, parameters, options) {
|
|||||||
|
|
||||||
var group = parameters.group;
|
var group = parameters.group;
|
||||||
|
|
||||||
|
var group_id = getFieldName(group, options);
|
||||||
|
|
||||||
var group_options = options.groups[group] || {};
|
var group_options = options.groups[group] || {};
|
||||||
|
|
||||||
// Are we starting a new group?
|
// Are we starting a new group?
|
||||||
@@ -1810,12 +1915,12 @@ function constructField(name, parameters, options) {
|
|||||||
if (parameters.group != options.current_group) {
|
if (parameters.group != options.current_group) {
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class='panel form-panel' id='form-panel-${group}' group='${group}'>
|
<div class='panel form-panel' id='form-panel-${group_id}' group='${group}'>
|
||||||
<div class='panel-heading form-panel-heading' id='form-panel-heading-${group}'>`;
|
<div class='panel-heading form-panel-heading' id='form-panel-heading-${group_id}'>`;
|
||||||
if (group_options.collapsible) {
|
if (group_options.collapsible) {
|
||||||
html += `
|
html += `
|
||||||
<div data-bs-toggle='collapse' data-bs-target='#form-panel-content-${group}'>
|
<div data-bs-toggle='collapse' data-bs-target='#form-panel-content-${group_id}'>
|
||||||
<a href='#'><span id='group-icon-${group}' class='fas fa-angle-up'></span>
|
<a href='#'><span id='group-icon-${group_id}' class='fas fa-angle-up'></span>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
html += `<div>`;
|
html += `<div>`;
|
||||||
@@ -1829,7 +1934,7 @@ function constructField(name, parameters, options) {
|
|||||||
|
|
||||||
html += `
|
html += `
|
||||||
</div></div>
|
</div></div>
|
||||||
<div class='panel-content form-panel-content' id='form-panel-content-${group}'>
|
<div class='panel-content form-panel-content' id='form-panel-content-${group_id}'>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1837,7 +1942,7 @@ function constructField(name, parameters, options) {
|
|||||||
options.current_group = group;
|
options.current_group = group;
|
||||||
}
|
}
|
||||||
|
|
||||||
var form_classes = 'form-group';
|
var form_classes = options.form_classes || 'form-group';
|
||||||
|
|
||||||
if (parameters.errors) {
|
if (parameters.errors) {
|
||||||
form_classes += ' form-field-error';
|
form_classes += ' form-field-error';
|
||||||
@@ -1848,7 +1953,13 @@ function constructField(name, parameters, options) {
|
|||||||
html += parameters.before;
|
html += parameters.before;
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `<div id='div_${field_name}' class='${form_classes}'>`;
|
var hover_title = '';
|
||||||
|
|
||||||
|
if (parameters.help_text) {
|
||||||
|
hover_title = ` title='${parameters.help_text}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<div id='div_id_${field_name}' class='${form_classes}' ${hover_title}>`;
|
||||||
|
|
||||||
// Add a label
|
// Add a label
|
||||||
if (!options.hideLabels) {
|
if (!options.hideLabels) {
|
||||||
@@ -1886,13 +1997,13 @@ function constructField(name, parameters, options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html += constructInput(name, parameters, options);
|
html += constructInput(field_name, parameters, options);
|
||||||
|
|
||||||
if (extra) {
|
if (extra) {
|
||||||
|
|
||||||
if (!parameters.required) {
|
if (!parameters.required && !options.hideClearButton) {
|
||||||
html += `
|
html += `
|
||||||
<span class='input-group-text form-clear' id='clear_${name}' title='{% trans "Clear input" %}'>
|
<span class='input-group-text form-clear' id='clear_${field_name}' title='{% trans "Clear input" %}'>
|
||||||
<span class='icon-red fas fa-backspace'></span>
|
<span class='icon-red fas fa-backspace'></span>
|
||||||
</span>`;
|
</span>`;
|
||||||
}
|
}
|
||||||
@@ -1909,7 +2020,7 @@ function constructField(name, parameters, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Div for error messages
|
// Div for error messages
|
||||||
html += `<div id='errors-${name}'></div>`;
|
html += `<div id='errors-${field_name}'></div>`;
|
||||||
|
|
||||||
|
|
||||||
html += `</div>`; // controls
|
html += `</div>`; // controls
|
||||||
@@ -2018,7 +2129,7 @@ function constructInput(name, parameters, options) {
|
|||||||
|
|
||||||
|
|
||||||
// Construct a set of default input options which apply to all input types
|
// Construct a set of default input options which apply to all input types
|
||||||
function constructInputOptions(name, classes, type, parameters) {
|
function constructInputOptions(name, classes, type, parameters, options={}) {
|
||||||
|
|
||||||
var opts = [];
|
var opts = [];
|
||||||
|
|
||||||
@@ -2100,11 +2211,18 @@ function constructInputOptions(name, classes, type, parameters) {
|
|||||||
if (parameters.multiline) {
|
if (parameters.multiline) {
|
||||||
return `<textarea ${opts.join(' ')}></textarea>`;
|
return `<textarea ${opts.join(' ')}></textarea>`;
|
||||||
} else if (parameters.type == 'boolean') {
|
} else if (parameters.type == 'boolean') {
|
||||||
|
|
||||||
|
var help_text = '';
|
||||||
|
|
||||||
|
if (!options.hideLabels && parameters.help_text) {
|
||||||
|
help_text = `<em><small>${parameters.help_text}</small></em>`;
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class='form-check form-switch'>
|
<div class='form-check form-switch'>
|
||||||
<input ${opts.join(' ')}>
|
<input ${opts.join(' ')}>
|
||||||
<label class='form-check-label' for=''>
|
<label class='form-check-label' for=''>
|
||||||
<em><small>${parameters.help_text}</small></em>
|
${help_text}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -2127,13 +2245,14 @@ function constructHiddenInput(name, parameters) {
|
|||||||
|
|
||||||
|
|
||||||
// Construct a "checkbox" input
|
// Construct a "checkbox" input
|
||||||
function constructCheckboxInput(name, parameters) {
|
function constructCheckboxInput(name, parameters, options={}) {
|
||||||
|
|
||||||
return constructInputOptions(
|
return constructInputOptions(
|
||||||
name,
|
name,
|
||||||
'form-check-input',
|
'form-check-input',
|
||||||
'checkbox',
|
'checkbox',
|
||||||
parameters
|
parameters,
|
||||||
|
options
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,15 +62,16 @@ function imageHoverIcon(url) {
|
|||||||
* @param {String} url is the image URL
|
* @param {String} url is the image URL
|
||||||
* @returns html <img> tag
|
* @returns html <img> tag
|
||||||
*/
|
*/
|
||||||
function thumbnailImage(url) {
|
function thumbnailImage(url, options={}) {
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
url = blankImage();
|
url = blankImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Support insertion of custom classes
|
// TODO: Support insertion of custom classes
|
||||||
|
var title = options.title || '';
|
||||||
|
|
||||||
var html = `<img class='hover-img-thumb' src='${url}'>`;
|
var html = `<img class='hover-img-thumb' src='${url}' title='${title}'>`;
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ function createNewModal(options={}) {
|
|||||||
$(modal_name).find('#modal-form-cancel').hide();
|
$(modal_name).find('#modal-form-cancel').hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Steal keyboard focus
|
||||||
|
$(modal_name).focus();
|
||||||
|
|
||||||
// Return the "name" of the modal
|
// Return the "name" of the modal
|
||||||
return modal_name;
|
return modal_name;
|
||||||
}
|
}
|
||||||
@@ -372,6 +375,14 @@ function attachSelect(modal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function attachBootstrapCheckbox(modal) {
|
||||||
|
/* Attach 'switch' functionality to any checkboxes on the form */
|
||||||
|
|
||||||
|
$(modal + ' .checkboxinput').addClass('form-check-input');
|
||||||
|
$(modal + ' .checkboxinput').wrap(`<div class='form-check form-switch'></div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function loadingMessageContent() {
|
function loadingMessageContent() {
|
||||||
/* Render a 'loading' message to display in a form
|
/* Render a 'loading' message to display in a form
|
||||||
* when waiting for a response from the server
|
* when waiting for a response from the server
|
||||||
@@ -686,7 +697,9 @@ function injectModalForm(modal, form_html) {
|
|||||||
* Updates the HTML of the form content, and then applies some other updates
|
* Updates the HTML of the form content, and then applies some other updates
|
||||||
*/
|
*/
|
||||||
$(modal).find('.modal-form-content').html(form_html);
|
$(modal).find('.modal-form-content').html(form_html);
|
||||||
|
|
||||||
attachSelect(modal);
|
attachSelect(modal);
|
||||||
|
attachBootstrapCheckbox(modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ function renderPart(name, data, parameters, options) {
|
|||||||
html += ` <span>${data.full_name || data.name}</span>`;
|
html += ` <span>${data.full_name || data.name}</span>`;
|
||||||
|
|
||||||
if (data.description) {
|
if (data.description) {
|
||||||
html += ` - <i>${data.description}</i>`;
|
html += ` - <i><small>${data.description}</small></i>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
var extra = '';
|
var extra = '';
|
||||||
@@ -221,13 +221,47 @@ function renderOwner(name, data, parameters, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Renderer for "PurchaseOrder" model
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
function renderPurchaseOrder(name, data, parameters, options) {
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX;
|
||||||
|
|
||||||
|
var thumbnail = null;
|
||||||
|
|
||||||
|
html += `<span>${prefix}${data.reference}</span>`;
|
||||||
|
|
||||||
|
if (data.supplier_detail) {
|
||||||
|
thumbnail = data.supplier_detail.thumbnail || data.supplier_detail.image;
|
||||||
|
|
||||||
|
html += ' - ' + select2Thumbnail(thumbnail);
|
||||||
|
html += `<span>${data.supplier_detail.name}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.description) {
|
||||||
|
html += ` - <em>${data.description}</em>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<span class='float-right'>
|
||||||
|
<small>
|
||||||
|
{% trans "Order ID" %}: ${data.pk}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Renderer for "SalesOrder" model
|
// Renderer for "SalesOrder" model
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
function renderSalesOrder(name, data, parameters, options) {
|
function renderSalesOrder(name, data, parameters, options) {
|
||||||
var html = `<span>${data.reference}</span>`;
|
var html = `<span>${data.reference}</span>`;
|
||||||
|
|
||||||
if (data.description) {
|
if (data.description) {
|
||||||
html += ` - <i>${data.description}</i>`;
|
html += ` - <em>${data.description}</em>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
exportStock,
|
exportStock,
|
||||||
findStockItemBySerialNumber,
|
findStockItemBySerialNumber,
|
||||||
loadInstalledInTable,
|
loadInstalledInTable,
|
||||||
|
loadStockAllocationTable,
|
||||||
loadStockLocationTable,
|
loadStockLocationTable,
|
||||||
loadStockTable,
|
loadStockTable,
|
||||||
loadStockTestResultsTable,
|
loadStockTestResultsTable,
|
||||||
@@ -2203,6 +2204,157 @@ function loadStockTable(table, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Display a table of allocated stock, for either a part or stock item
|
||||||
|
* Allocations are displayed for:
|
||||||
|
*
|
||||||
|
* a) Sales Orders
|
||||||
|
* b) Build Orders
|
||||||
|
*/
|
||||||
|
function loadStockAllocationTable(table, options={}) {
|
||||||
|
|
||||||
|
var params = options.params || {};
|
||||||
|
|
||||||
|
params.build_detail = true;
|
||||||
|
|
||||||
|
var filterListElement = options.filterList || '#filter-list-allocations';
|
||||||
|
|
||||||
|
var filters = {};
|
||||||
|
|
||||||
|
var filterKey = options.filterKey || options.name || 'allocations';
|
||||||
|
|
||||||
|
var original = {};
|
||||||
|
|
||||||
|
for (var k in params) {
|
||||||
|
original[k] = params[k];
|
||||||
|
filters[k] = params[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
setupFilterList(filterKey, table, filterListElement);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* We have two separate API queries to make here:
|
||||||
|
* a) Build Order Allocations
|
||||||
|
* b) Sales Order Allocations
|
||||||
|
*
|
||||||
|
* We will let the call to inventreeTable take care of build orders,
|
||||||
|
* and then load sales orders after that.
|
||||||
|
*/
|
||||||
|
table.inventreeTable({
|
||||||
|
url: '{% url "api-build-item-list" %}',
|
||||||
|
name: 'allocations',
|
||||||
|
original: original,
|
||||||
|
method: 'get',
|
||||||
|
queryParams: filters,
|
||||||
|
sidePagination: 'client',
|
||||||
|
showColumns: false,
|
||||||
|
onLoadSuccess: function(tableData) {
|
||||||
|
|
||||||
|
var query_params = params;
|
||||||
|
|
||||||
|
query_params.customer_detail = true;
|
||||||
|
query_params.order_detail = true;
|
||||||
|
|
||||||
|
delete query_params.build_detail;
|
||||||
|
|
||||||
|
// Load sales order allocation data
|
||||||
|
inventreeGet('{% url "api-so-allocation-list" %}', query_params, {
|
||||||
|
success: function(data) {
|
||||||
|
// Update table to include sales order data
|
||||||
|
$(table).bootstrapTable('append', data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
field: 'order',
|
||||||
|
title: '{% trans "Order" %}',
|
||||||
|
formatter: function(value, row) {
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
if (row.build) {
|
||||||
|
|
||||||
|
// Add an icon for the part being built
|
||||||
|
html += thumbnailImage(row.build_detail.part_detail.thumbnail, {
|
||||||
|
title: row.build_detail.part_detail.full_name
|
||||||
|
});
|
||||||
|
|
||||||
|
html += ' ';
|
||||||
|
|
||||||
|
html += renderLink(
|
||||||
|
global_settings.BUILDORDER_REFERENCE_PREFIX + row.build_detail.reference,
|
||||||
|
`/build/${row.build}/`
|
||||||
|
);
|
||||||
|
|
||||||
|
html += makeIconBadge('fa-tools', '{% trans "Build Order" %}');
|
||||||
|
} else if (row.order) {
|
||||||
|
|
||||||
|
// Add an icon for the customer
|
||||||
|
html += thumbnailImage(row.customer_detail.thumbnail || row.customer_detail.image, {
|
||||||
|
title: row.customer_detail.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
html += ' ';
|
||||||
|
|
||||||
|
html += renderLink(
|
||||||
|
global_settings.SALESORDER_REFERENCE_PREFIX + row.order_detail.reference,
|
||||||
|
`/order/sales-order/${row.order}/`
|
||||||
|
);
|
||||||
|
html += makeIconBadge('fa-truck', '{% trans "Sales Order" %}');
|
||||||
|
} else {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'description',
|
||||||
|
title: '{% trans "Description" %}',
|
||||||
|
formatter: function(value, row) {
|
||||||
|
if (row.order_detail) {
|
||||||
|
return row.order_detail.description;
|
||||||
|
} else if (row.build_detail) {
|
||||||
|
return row.build_detail.title;
|
||||||
|
} else {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: '{% trans "Order Status" %}',
|
||||||
|
formatter: function(value, row) {
|
||||||
|
if (row.build) {
|
||||||
|
return buildStatusDisplay(row.build_detail.status);
|
||||||
|
} else if (row.order) {
|
||||||
|
return salesOrderStatusDisplay(row.order_detail.status);
|
||||||
|
} else {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'quantity',
|
||||||
|
title: '{% trans "Allocated Quantity" %}',
|
||||||
|
formatter: function(value, row) {
|
||||||
|
var text = value;
|
||||||
|
var pk = row.stock_item || row.item;
|
||||||
|
|
||||||
|
if (pk) {
|
||||||
|
var url = `/stock/item/${pk}/`;
|
||||||
|
return renderLink(text, url);
|
||||||
|
} else {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Display a table of stock locations
|
* Display a table of stock locations
|
||||||
*/
|
*/
|
||||||
@@ -2252,7 +2404,6 @@ function loadStockLocationTable(table, options) {
|
|||||||
method: 'get',
|
method: 'get',
|
||||||
url: options.url || '{% url "api-location-list" %}',
|
url: options.url || '{% url "api-location-list" %}',
|
||||||
queryParams: filters,
|
queryParams: filters,
|
||||||
sidePagination: 'server',
|
|
||||||
name: 'location',
|
name: 'location',
|
||||||
original: original,
|
original: original,
|
||||||
showColumns: true,
|
showColumns: true,
|
||||||
|
|||||||
@@ -121,12 +121,12 @@
|
|||||||
{% if user.is_staff and not demo %}
|
{% if user.is_staff and not demo %}
|
||||||
<li><a class='dropdown-item' href="/admin/"><span class="fas fa-user-shield"></span> {% trans "Admin" %}</a></li>
|
<li><a class='dropdown-item' href="/admin/"><span class="fas fa-user-shield"></span> {% trans "Admin" %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<li><a class='dropdown-item' href="{% url 'settings' %}"><span class="fas fa-cog"></span> {% trans "Settings" %}</a></li>
|
||||||
<li><a class='dropdown-item' href="{% url 'account_logout' %}"><span class="fas fa-sign-out-alt"></span> {% trans "Logout" %}</a></li>
|
<li><a class='dropdown-item' href="{% url 'account_logout' %}"><span class="fas fa-sign-out-alt"></span> {% trans "Logout" %}</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li><a class='dropdown-item' href="{% url 'account_login' %}"><span class="fas fa-sign-in-alt"></span> {% trans "Login" %}</a></li>
|
<li><a class='dropdown-item' href="{% url 'account_login' %}"><span class="fas fa-sign-in-alt"></span> {% trans "Login" %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<hr>
|
<hr>
|
||||||
<li><a class='dropdown-item' href="{% url 'settings' %}"><span class="fas fa-cog"></span> {% trans "Settings" %}</a></li>
|
|
||||||
<li id='launch-stats'>
|
<li id='launch-stats'>
|
||||||
<a class='dropdown-item' href='#'>
|
<a class='dropdown-item' href='#'>
|
||||||
{% if system_healthy or not user.is_staff %}
|
{% if system_healthy or not user.is_staff %}
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
<h6>
|
<h6>
|
||||||
<i class="bi bi-bootstrap"></i>
|
<i class="bi bi-bootstrap"></i>
|
||||||
{% if icon %}<span class='sidebar-item-icon fas {{ icon }}'></span>{% endif %}
|
{% if icon %}<span class='sidebar-item-icon fas {{ icon }}'></span>{% endif %}
|
||||||
{% if text %}<span class='sidebar-item-text' style='display: none;'>{{ text }}</span>{% endif %}
|
{% if text %}<span class='sidebar-item-text' style='display: none;'><strong>{{ text }}</strong></span>{% endif %}
|
||||||
</h6>
|
</h6>
|
||||||
</span>
|
</span>
|
||||||
@@ -177,6 +177,11 @@ class RuleSet(models.Model):
|
|||||||
'django_q_success',
|
'django_q_success',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
RULESET_CHANGE_INHERIT = [
|
||||||
|
('part', 'partparameter'),
|
||||||
|
('part', 'bomitem'),
|
||||||
|
]
|
||||||
|
|
||||||
RULE_OPTIONS = [
|
RULE_OPTIONS = [
|
||||||
'can_view',
|
'can_view',
|
||||||
'can_add',
|
'can_add',
|
||||||
@@ -229,6 +234,16 @@ class RuleSet(models.Model):
|
|||||||
if check_user_role(user, role, permission):
|
if check_user_role(user, role, permission):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Check for children models which inherits from parent role
|
||||||
|
for (parent, child) in cls.RULESET_CHANGE_INHERIT:
|
||||||
|
# Get child model name
|
||||||
|
parent_child_string = f'{parent}_{child}'
|
||||||
|
|
||||||
|
if parent_child_string == table:
|
||||||
|
# Check if parent role has change permission
|
||||||
|
if check_user_role(user, parent, 'change'):
|
||||||
|
return True
|
||||||
|
|
||||||
# Print message instead of throwing an error
|
# Print message instead of throwing an error
|
||||||
name = getattr(user, 'name', user.pk)
|
name = getattr(user, 'name', user.pk)
|
||||||
|
|
||||||
@@ -454,6 +469,28 @@ def update_group_roles(group, debug=False):
|
|||||||
if debug:
|
if debug:
|
||||||
print(f"Removing permission {perm} from group {group.name}")
|
print(f"Removing permission {perm} from group {group.name}")
|
||||||
|
|
||||||
|
# Enable all action permissions for certain children models
|
||||||
|
# if parent model has 'change' permission
|
||||||
|
for (parent, child) in RuleSet.RULESET_CHANGE_INHERIT:
|
||||||
|
parent_change_perm = f'{parent}.change_{parent}'
|
||||||
|
parent_child_string = f'{parent}_{child}'
|
||||||
|
|
||||||
|
# Check if parent change permission exists
|
||||||
|
if parent_change_perm in group_permissions:
|
||||||
|
# Add child model permissions
|
||||||
|
for action in ['add', 'change', 'delete']:
|
||||||
|
child_perm = f'{parent}.{action}_{child}'
|
||||||
|
|
||||||
|
# Check if child permission not already in group
|
||||||
|
if child_perm not in group_permissions:
|
||||||
|
# Create permission object
|
||||||
|
add_model(parent_child_string, action, ruleset.can_delete)
|
||||||
|
# Add to group
|
||||||
|
permission = get_permission_object(child_perm)
|
||||||
|
if permission:
|
||||||
|
group.permissions.add(permission)
|
||||||
|
print(f"Adding permission {child_perm} to group {group.name}")
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Group, dispatch_uid='create_missing_rule_sets')
|
@receiver(post_save, sender=Group, dispatch_uid='create_missing_rule_sets')
|
||||||
def create_missing_rule_sets(sender, instance, **kwargs):
|
def create_missing_rule_sets(sender, instance, **kwargs):
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
|
|
||||||
# InvenTree
|
# InvenTree
|
||||||
|
|
||||||
|
<p><a href="https://twitter.com/intent/follow?screen_name=inventreedb">
|
||||||
|
<img src="https://img.shields.io/twitter/follow/inventreedb?style=social&logo=twitter"
|
||||||
|
alt="follow on Twitter"></a></p>
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://coveralls.io/github/inventree/InvenTree)
|
[](https://coveralls.io/github/inventree/InvenTree)
|
||||||
[](https://crowdin.com/project/inventree)
|
[](https://crowdin.com/project/inventree)
|
||||||
@@ -33,12 +37,6 @@ InvenTree is supported by a [companion mobile app](https://inventree.readthedocs
|
|||||||
|
|
||||||
- [**Download InvenTree from the Apple App Store**](https://apps.apple.com/au/app/inventree/id1581731101#?platform=iphone)
|
- [**Download InvenTree from the Apple App Store**](https://apps.apple.com/au/app/inventree/id1581731101#?platform=iphone)
|
||||||
|
|
||||||
# Translation
|
|
||||||
|
|
||||||
Native language translation of the InvenTree web application is [community contributed via crowdin](https://crowdin.com/project/inventree). **Contributions are welcomed and encouraged**.
|
|
||||||
|
|
||||||
To contribute to the translation effort, navigate to the [InvenTree crowdin project](https://crowdin.com/project/inventree), create a free account, and start making translations suggestions for your language of choice!
|
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
|
|
||||||
For InvenTree documentation, refer to the [InvenTree documentation website](https://inventree.readthedocs.io/en/latest/).
|
For InvenTree documentation, refer to the [InvenTree documentation website](https://inventree.readthedocs.io/en/latest/).
|
||||||
@@ -64,6 +62,12 @@ InvenTree is designed to be extensible, and provides multiple options for integr
|
|||||||
|
|
||||||
Contributions are welcomed and encouraged. Please help to make this project even better! Refer to the [contribution page](https://inventree.readthedocs.io/en/latest/contribute/).
|
Contributions are welcomed and encouraged. Please help to make this project even better! Refer to the [contribution page](https://inventree.readthedocs.io/en/latest/contribute/).
|
||||||
|
|
||||||
|
# Translation
|
||||||
|
|
||||||
|
Native language translation of the InvenTree web application is [community contributed via crowdin](https://crowdin.com/project/inventree). **Contributions are welcomed and encouraged**.
|
||||||
|
|
||||||
|
To contribute to the translation effort, navigate to the [InvenTree crowdin project](https://crowdin.com/project/inventree), create a free account, and start making translations suggestions for your language of choice!
|
||||||
|
|
||||||
# Donate
|
# Donate
|
||||||
|
|
||||||
If you use InvenTree and find it to be useful, please consider making a donation toward its continued development.
|
If you use InvenTree and find it to be useful, please consider making a donation toward its continued development.
|
||||||
|
|||||||
@@ -14,4 +14,4 @@ INVENTREE_DB_USER=pguser
|
|||||||
INVENTREE_DB_PASSWORD=pgpassword
|
INVENTREE_DB_PASSWORD=pgpassword
|
||||||
|
|
||||||
# Enable plugins?
|
# Enable plugins?
|
||||||
INVENTREE_PLUGINS_ENABLED=False
|
INVENTREE_PLUGINS_ENABLED=True
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user