mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-29 12:06:44 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue2279
This commit is contained in:
commit
9eb238c85e
@ -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:
|
||||||
update_exchange_rates()
|
try:
|
||||||
|
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
|
||||||
return Decimal(str(data))
|
try:
|
||||||
|
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}'")
|
||||||
|
|
||||||
backend.update_rates(base_currency=base)
|
try:
|
||||||
|
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,9 +156,11 @@ 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"))
|
||||||
|
|
||||||
# The build output must have all tracked parts allocated
|
if to_complete:
|
||||||
if not build.isFullyAllocated(output):
|
|
||||||
raise ValidationError(_("This build output is not fully allocated"))
|
# The build output must have all tracked parts allocated
|
||||||
|
if not build.isFullyAllocated(output):
|
||||||
|
raise ValidationError(_("This build output is not fully allocated"))
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
@ -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)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||||
|
@ -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 %}",
|
||||||
{
|
{
|
||||||
|
@ -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'),
|
||||||
]
|
]
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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" %}
|
||||||
|
108
InvenTree/part/templates/part/upload_bom.html
Normal file
108
InvenTree/part/templates/part/upload_bom.html
Normal file
@ -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,
|
||||||
|
298
InvenTree/part/test_bom_import.py
Normal file
298
InvenTree/part/test_bom_import.py
Normal file
@ -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'),
|
||||||
|
@ -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,10 +392,11 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
|
|||||||
context.update(**ctx)
|
context.update(**ctx)
|
||||||
|
|
||||||
# Pricing information
|
# Pricing information
|
||||||
ctx = self.get_pricing(self.get_quantity())
|
if InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False):
|
||||||
ctx['form'] = self.form_class(initial=self.get_initials())
|
ctx = self.get_pricing(self.get_quantity())
|
||||||
|
ctx['form'] = self.form_class(initial=self.get_initials())
|
||||||
|
|
||||||
context.update(ctx)
|
context.update(ctx)
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
@ -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,13 +20,17 @@ class PluginAppConfig(AppConfig):
|
|||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
if settings.PLUGINS_ENABLED:
|
if settings.PLUGINS_ENABLED:
|
||||||
logger.info('Loading InvenTree plugins')
|
|
||||||
|
|
||||||
if not registry.is_loading:
|
if isImportingData():
|
||||||
# this is the first startup
|
logger.info('Skipping plugin loading for data import')
|
||||||
registry.collect_plugins()
|
else:
|
||||||
registry.load_plugins()
|
logger.info('Loading InvenTree plugins')
|
||||||
|
|
||||||
# drop out of maintenance
|
if not registry.is_loading:
|
||||||
# makes sure we did not have an error in reloading and maintenance is still active
|
# this is the first startup
|
||||||
set_maintenance_mode(False)
|
registry.collect_plugins()
|
||||||
|
registry.load_plugins()
|
||||||
|
|
||||||
|
# drop out of maintenance
|
||||||
|
# makes sure we did not have an error in reloading and maintenance is still active
|
||||||
|
set_maintenance_mode(False)
|
||||||
|
@ -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
|
||||||
|
18
InvenTree/plugin/migrations/0004_alter_pluginsetting_key.py
Normal file
18
InvenTree/plugin/migrations/0004_alter_pluginsetting_key.py
Normal file
@ -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}")
|
||||||
|
@ -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'),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Set default location (if not provided)
|
||||||
|
if 'location' not in data:
|
||||||
|
location = part.get_default_location()
|
||||||
|
|
||||||
|
if location:
|
||||||
|
data['location'] = location.pk
|
||||||
|
|
||||||
|
# An expiry date was *not* specified - try to infer it!
|
||||||
|
if 'expiry_date' not in data:
|
||||||
|
|
||||||
|
if part.default_expiry > 0:
|
||||||
|
data['expiry_date'] = datetime.now().date() + timedelta(days=part.default_expiry)
|
||||||
|
|
||||||
|
# Attempt to extract serial numbers from submitted data
|
||||||
|
serials = None
|
||||||
|
|
||||||
|
# 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!
|
||||||
|
try:
|
||||||
|
serials = extract_serial_numbers(serial_numbers, quantity, part.getLatestSerialNumberInt())
|
||||||
|
except DjangoValidationError as e:
|
||||||
|
raise ValidationError({
|
||||||
|
'quantity': e.messages,
|
||||||
|
'serial_numbers': e.messages,
|
||||||
|
})
|
||||||
|
|
||||||
|
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():
|
with transaction.atomic():
|
||||||
|
|
||||||
# Create an initial stock item
|
# Create an initial StockItem object
|
||||||
item = serializer.save()
|
item = serializer.save()
|
||||||
|
|
||||||
# A location was *not* specified - try to infer it
|
if serials:
|
||||||
if 'location' not in data:
|
# Assign the first serial number to the "master" item
|
||||||
item.location = item.part.get_default_location()
|
item.serial = serials[0]
|
||||||
|
|
||||||
# An expiry date was *not* specified - try to infer it!
|
# Save the item (with user information)
|
||||||
if 'expiry_date' not in data:
|
|
||||||
|
|
||||||
if item.part.default_expiry > 0:
|
|
||||||
item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry)
|
|
||||||
|
|
||||||
# fetch serial numbers
|
|
||||||
serials = None
|
|
||||||
|
|
||||||
if serial_numbers:
|
|
||||||
# If serial numbers are specified, check that they match!
|
|
||||||
try:
|
|
||||||
serials = extract_serial_numbers(serial_numbers, quantity, item.part.getLatestSerialNumberInt())
|
|
||||||
except DjangoValidationError as e:
|
|
||||||
raise ValidationError({
|
|
||||||
'quantity': e.messages,
|
|
||||||
'serial_numbers': e.messages,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Finally, 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)
|
response_data = {
|
||||||
|
'quantity': quantity,
|
||||||
|
'serial_numbers': serials,
|
||||||
|
}
|
||||||
|
|
||||||
# Delete the original item
|
else:
|
||||||
item.delete()
|
response_data = serializer.data
|
||||||
|
|
||||||
response_data = {
|
return Response(response_data, status=status.HTTP_201_CREATED, headers=self.get_success_headers(serializer.data))
|
||||||
'quantity': quantity,
|
|
||||||
'serial_numbers': serials,
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response(response_data, status=status.HTTP_201_CREATED, headers=headers)
|
|
||||||
|
|
||||||
except DjangoValidationError as e:
|
|
||||||
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 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 %}>
|
||||||
|
<span class='fas fa-edit icon-green'></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td>
|
|
||||||
{{ setting.description }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<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 %}>
|
|
||||||
<span class='fas fa-edit icon-green'></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</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,16 +982,22 @@ 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={}) {
|
||||||
|
|
||||||
// Remove the individual error messages
|
if (options && options.modal) {
|
||||||
$(options.modal).find('.form-error-message').remove();
|
// Remove the individual error messages
|
||||||
|
$(options.modal).find('.form-error-message').remove();
|
||||||
|
|
||||||
// Remove the "has error" class
|
// Remove the "has error" class
|
||||||
$(options.modal).find('.form-field-error').removeClass('form-field-error');
|
$(options.modal).find('.form-field-error').removeClass('form-field-error');
|
||||||
|
|
||||||
// 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
|
||||||
$(options.modal).find('#modal-form-submit').prop('disabled', false);
|
if (options.modal) {
|
||||||
|
$(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);
|
||||||
$(options.modal).find(`#div_id_${field_name}`).addClass('form-field-error');
|
|
||||||
|
|
||||||
var field_dom = $(options.modal).find(`#errors-${field_name}`);
|
var field_dom = null;
|
||||||
|
|
||||||
if (field_dom) {
|
if (options && options.modal) {
|
||||||
|
$(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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field_dom.exists()) {
|
||||||
|
|
||||||
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,20 +221,54 @@ function renderOwner(name, data, parameters, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Renderer for "SalesOrder" model
|
// Renderer for "PurchaseOrder" model
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
function renderSalesOrder(name, data, parameters, options) {
|
function renderPurchaseOrder(name, data, parameters, options) {
|
||||||
var html = `<span>${data.reference}</span>`;
|
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) {
|
if (data.description) {
|
||||||
html += ` - <i>${data.description}</i>`;
|
html += ` - <em>${data.description}</em>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<span class='float-right'>
|
<span class='float-right'>
|
||||||
<small>
|
<small>
|
||||||
{% trans "Order ID" %}: ${data.pk}
|
{% trans "Order ID" %}: ${data.pk}
|
||||||
</small>
|
</small>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Renderer for "SalesOrder" model
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
function renderSalesOrder(name, data, parameters, options) {
|
||||||
|
var html = `<span>${data.reference}</span>`;
|
||||||
|
|
||||||
|
if (data.description) {
|
||||||
|
html += ` - <em>${data.description}</em>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<span class='float-right'>
|
||||||
|
<small>
|
||||||
|
{% trans "Order ID" %}: ${data.pk}
|
||||||
|
</small>
|
||||||
</span>`;
|
</span>`;
|
||||||
|
|
||||||
return html;
|
return 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):
|
||||||
|
16
README.md
16
README.md
@ -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
Loading…
x
Reference in New Issue
Block a user