mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
1e5a2acb52
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -18,7 +18,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: "Problem statement"
|
label: "Problem statement"
|
||||||
description: "A clear and concise description of what the solved problem or feature request is."
|
description: "A clear and concise description of what the solved problem or feature request is."
|
||||||
placeholder: "I am always struggeling with ..."
|
placeholder: "I am always struggling with ..."
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: solution
|
id: solution
|
||||||
validations:
|
validations:
|
||||||
|
@ -4,6 +4,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
@ -24,6 +25,7 @@ import InvenTree.format
|
|||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from InvenTree.fields import InvenTreeURLField
|
from InvenTree.fields import InvenTreeURLField
|
||||||
|
from InvenTree.sanitizer import sanitize_svg
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
@ -383,8 +385,16 @@ class InvenTreeAttachment(models.Model):
|
|||||||
'link': _('Missing external link'),
|
'link': _('Missing external link'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if self.attachment.name.lower().endswith('.svg'):
|
||||||
|
self.attachment.file.file = self.clean_svg(self.attachment)
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean_svg(self, field):
|
||||||
|
"""Sanitize SVG file before saving."""
|
||||||
|
cleaned = sanitize_svg(field.file.read())
|
||||||
|
return BytesIO(bytes(cleaned, 'utf8'))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Human name for attachment."""
|
"""Human name for attachment."""
|
||||||
if self.attachment is not None:
|
if self.attachment is not None:
|
||||||
|
67
InvenTree/InvenTree/sanitizer.py
Normal file
67
InvenTree/InvenTree/sanitizer.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"""Functions to sanitize user input files."""
|
||||||
|
from bleach import clean
|
||||||
|
from bleach.css_sanitizer import CSSSanitizer
|
||||||
|
|
||||||
|
ALLOWED_ELEMENTS_SVG = [
|
||||||
|
'a', 'animate', 'animateColor', 'animateMotion',
|
||||||
|
'animateTransform', 'circle', 'defs', 'desc', 'ellipse', 'font-face',
|
||||||
|
'font-face-name', 'font-face-src', 'g', 'glyph', 'hkern',
|
||||||
|
'linearGradient', 'line', 'marker', 'metadata', 'missing-glyph',
|
||||||
|
'mpath', 'path', 'polygon', 'polyline', 'radialGradient', 'rect',
|
||||||
|
'set', 'stop', 'svg', 'switch', 'text', 'title', 'tspan', 'use'
|
||||||
|
]
|
||||||
|
|
||||||
|
ALLOWED_ATTRIBUTES_SVG = [
|
||||||
|
'accent-height', 'accumulate', 'additive', 'alphabetic',
|
||||||
|
'arabic-form', 'ascent', 'attributeName', 'attributeType',
|
||||||
|
'baseProfile', 'bbox', 'begin', 'by', 'calcMode', 'cap-height',
|
||||||
|
'class', 'color', 'color-rendering', 'content', 'cx', 'cy', 'd', 'dx',
|
||||||
|
'dy', 'descent', 'display', 'dur', 'end', 'fill', 'fill-opacity',
|
||||||
|
'fill-rule', 'font-family', 'font-size', 'font-stretch', 'font-style',
|
||||||
|
'font-variant', 'font-weight', 'from', 'fx', 'fy', 'g1', 'g2',
|
||||||
|
'glyph-name', 'gradientUnits', 'hanging', 'height', 'horiz-adv-x',
|
||||||
|
'horiz-origin-x', 'id', 'ideographic', 'k', 'keyPoints',
|
||||||
|
'keySplines', 'keyTimes', 'lang', 'marker-end', 'marker-mid',
|
||||||
|
'marker-start', 'markerHeight', 'markerUnits', 'markerWidth',
|
||||||
|
'mathematical', 'max', 'min', 'name', 'offset', 'opacity', 'orient',
|
||||||
|
'origin', 'overline-position', 'overline-thickness', 'panose-1',
|
||||||
|
'path', 'pathLength', 'points', 'preserveAspectRatio', 'r', 'refX',
|
||||||
|
'refY', 'repeatCount', 'repeatDur', 'requiredExtensions',
|
||||||
|
'requiredFeatures', 'restart', 'rotate', 'rx', 'ry', 'slope',
|
||||||
|
'stemh', 'stemv', 'stop-color', 'stop-opacity',
|
||||||
|
'strikethrough-position', 'strikethrough-thickness', 'stroke',
|
||||||
|
'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap',
|
||||||
|
'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity',
|
||||||
|
'stroke-width', 'systemLanguage', 'target', 'text-anchor', 'to',
|
||||||
|
'transform', 'type', 'u1', 'u2', 'underline-position',
|
||||||
|
'underline-thickness', 'unicode', 'unicode-range', 'units-per-em',
|
||||||
|
'values', 'version', 'viewBox', 'visibility', 'width', 'widths', 'x',
|
||||||
|
'x-height', 'x1', 'x2', 'xlink:actuate', 'xlink:arcrole',
|
||||||
|
'xlink:href', 'xlink:role', 'xlink:show', 'xlink:title',
|
||||||
|
'xlink:type', 'xml:base', 'xml:lang', 'xml:space', 'xmlns',
|
||||||
|
'xmlns:xlink', 'y', 'y1', 'y2', 'zoomAndPan', 'style'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_svg(file_data: str, strip: bool = True, elements: str = ALLOWED_ELEMENTS_SVG, attributes: str = ALLOWED_ATTRIBUTES_SVG) -> str:
|
||||||
|
"""Sanatize a SVG file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_data (str): SVG as string.
|
||||||
|
strip (bool, optional): Should invalid elements get removed. Defaults to True.
|
||||||
|
elements (str, optional): Allowed elements. Defaults to ALLOWED_ELEMENTS_SVG.
|
||||||
|
attributes (str, optional): Allowed attributes. Defaults to ALLOWED_ATTRIBUTES_SVG.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Sanitzied SVG file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cleaned = clean(
|
||||||
|
file_data,
|
||||||
|
tags=elements,
|
||||||
|
attributes=attributes,
|
||||||
|
strip=strip,
|
||||||
|
strip_comments=strip,
|
||||||
|
css_sanitizer=CSSSanitizer()
|
||||||
|
)
|
||||||
|
return cleaned
|
@ -207,13 +207,12 @@ function enableDragAndDrop(element, url, options) {
|
|||||||
formData,
|
formData,
|
||||||
{
|
{
|
||||||
success: function(data, status, xhr) {
|
success: function(data, status, xhr) {
|
||||||
console.log('Uploaded file via drag-and-drop');
|
|
||||||
if (options.success) {
|
if (options.success) {
|
||||||
options.success(data, status, xhr);
|
options.success(data, status, xhr);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: function(xhr, status, error) {
|
error: function(xhr, status, error) {
|
||||||
console.log('File upload failed');
|
console.error('File upload failed');
|
||||||
if (options.error) {
|
if (options.error) {
|
||||||
options.error(xhr, status, error);
|
options.error(xhr, status, error);
|
||||||
}
|
}
|
||||||
@ -222,7 +221,7 @@ function enableDragAndDrop(element, url, options) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log('Ignoring drag-and-drop event (not a file)');
|
console.warn('Ignoring drag-and-drop event (not a file)');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -23,6 +23,7 @@ import InvenTree.helpers
|
|||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from common.settings import currency_codes
|
from common.settings import currency_codes
|
||||||
|
from InvenTree.sanitizer import sanitize_svg
|
||||||
from part.models import Part, PartCategory
|
from part.models import Part, PartCategory
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
|
|
||||||
@ -878,3 +879,20 @@ class BarcodeMixinTest(helpers.InvenTreeTestCase):
|
|||||||
|
|
||||||
for barcode, hash in hashing_tests.items():
|
for barcode, hash in hashing_tests.items():
|
||||||
self.assertEqual(InvenTree.helpers.hash_barcode(barcode), hash)
|
self.assertEqual(InvenTree.helpers.hash_barcode(barcode), hash)
|
||||||
|
|
||||||
|
|
||||||
|
class SanitizerTest(TestCase):
|
||||||
|
"""Simple tests for sanitizer functions."""
|
||||||
|
|
||||||
|
def test_svg_sanitizer(self):
|
||||||
|
"""Test that SVGs are sanitized acordingly."""
|
||||||
|
valid_string = """<svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="svg2" height="400" width="400">{0}
|
||||||
|
<path id="path1" d="m -151.78571,359.62883 v 112.76373 l 97.068507,-56.04253 V 303.14815 Z" style="fill:#ddbc91;"></path>
|
||||||
|
</svg>"""
|
||||||
|
dangerous_string = valid_string.format('<script>alert();</script>')
|
||||||
|
|
||||||
|
# Test that valid string
|
||||||
|
self.assertEqual(valid_string, sanitize_svg(valid_string))
|
||||||
|
|
||||||
|
# Test that invalid string is cleanded
|
||||||
|
self.assertNotEqual(dangerous_string, sanitize_svg(dangerous_string))
|
||||||
|
@ -839,6 +839,10 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
# Get a list of all 'untracked' BOM items
|
# Get a list of all 'untracked' BOM items
|
||||||
for bom_item in self.untracked_bom_items:
|
for bom_item in self.untracked_bom_items:
|
||||||
|
|
||||||
|
if bom_item.consumable:
|
||||||
|
# Do not auto-allocate stock to consumable BOM items
|
||||||
|
continue
|
||||||
|
|
||||||
variant_parts = bom_item.sub_part.get_descendants(include_self=False)
|
variant_parts = bom_item.sub_part.get_descendants(include_self=False)
|
||||||
|
|
||||||
unallocated_quantity = self.unallocated_quantity(bom_item)
|
unallocated_quantity = self.unallocated_quantity(bom_item)
|
||||||
@ -972,7 +976,12 @@ class Build(MPTTModel, ReferenceIndexingMixin):
|
|||||||
return max(required - allocated, 0)
|
return max(required - allocated, 0)
|
||||||
|
|
||||||
def is_bom_item_allocated(self, bom_item, output=None):
|
def is_bom_item_allocated(self, bom_item, output=None):
|
||||||
"""Test if the supplied BomItem has been fully allocated!"""
|
"""Test if the supplied BomItem has been fully allocated"""
|
||||||
|
|
||||||
|
if bom_item.consumable:
|
||||||
|
# Consumable BOM items do not need to be allocated
|
||||||
|
return True
|
||||||
|
|
||||||
return self.unallocated_quantity(bom_item, output) == 0
|
return self.unallocated_quantity(bom_item, output) == 0
|
||||||
|
|
||||||
def is_fully_allocated(self, output):
|
def is_fully_allocated(self, output):
|
||||||
|
@ -490,6 +490,21 @@ class OverallocationChoice():
|
|||||||
class BuildCompleteSerializer(serializers.Serializer):
|
class BuildCompleteSerializer(serializers.Serializer):
|
||||||
"""DRF serializer for marking a BuildOrder as complete."""
|
"""DRF serializer for marking a BuildOrder as complete."""
|
||||||
|
|
||||||
|
def get_context_data(self):
|
||||||
|
"""Retrieve extra context data for this serializer.
|
||||||
|
|
||||||
|
This is so we can determine (at run time) whether the build is ready to be completed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
build = self.context['build']
|
||||||
|
|
||||||
|
return {
|
||||||
|
'overallocated': build.has_overallocated_parts(),
|
||||||
|
'allocated': build.are_untracked_parts_allocated(),
|
||||||
|
'remaining': build.remaining,
|
||||||
|
'incomplete': build.incomplete_count,
|
||||||
|
}
|
||||||
|
|
||||||
accept_overallocated = serializers.ChoiceField(
|
accept_overallocated = serializers.ChoiceField(
|
||||||
label=_('Overallocated Stock'),
|
label=_('Overallocated Stock'),
|
||||||
choices=list(OverallocationChoice.OPTIONS.items()),
|
choices=list(OverallocationChoice.OPTIONS.items()),
|
||||||
@ -766,6 +781,10 @@ class BuildAllocationSerializer(serializers.Serializer):
|
|||||||
quantity = item['quantity']
|
quantity = item['quantity']
|
||||||
output = item.get('output', None)
|
output = item.get('output', None)
|
||||||
|
|
||||||
|
# Ignore allocation for consumable BOM items
|
||||||
|
if bom_item.consumable:
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create a new BuildItem to allocate stock
|
# Create a new BuildItem to allocate stock
|
||||||
BuildItem.objects.create(
|
BuildItem.objects.create(
|
||||||
|
@ -228,11 +228,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
});
|
});
|
||||||
|
|
||||||
$("#build-complete").on('click', function() {
|
$("#build-complete").on('click', function() {
|
||||||
completeBuildOrder({{ build.pk }}, {
|
completeBuildOrder({{ build.pk }});
|
||||||
overallocated: {% if build.has_overallocated_parts %}true{% else %}false{% endif %},
|
|
||||||
allocated: {% if build.are_untracked_parts_allocated %}true{% else %}false{% endif %},
|
|
||||||
completed: {% if build.remaining == 0 %}true{% else %}false{% endif %},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -1642,6 +1642,16 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
|||||||
'default': True,
|
'default': True,
|
||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'TABLE_STRING_MAX_LENGTH': {
|
||||||
|
'name': _('Table String Length'),
|
||||||
|
'description': _('Maximimum length limit for strings displayed in table views'),
|
||||||
|
'validator': [
|
||||||
|
int,
|
||||||
|
MinValueValidator(0),
|
||||||
|
],
|
||||||
|
'default': 100,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
typ = 'user'
|
typ = 'user'
|
||||||
|
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
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
@ -1605,6 +1605,20 @@ class PartParameterList(ListCreateAPI):
|
|||||||
queryset = PartParameter.objects.all()
|
queryset = PartParameter.objects.all()
|
||||||
serializer_class = part_serializers.PartParameterSerializer
|
serializer_class = part_serializers.PartParameterSerializer
|
||||||
|
|
||||||
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
"""Return the serializer instance for this API endpoint.
|
||||||
|
|
||||||
|
If requested, extra detail fields are annotated to the queryset:
|
||||||
|
- template_detail
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
kwargs['template_detail'] = str2bool(self.request.GET.get('template_detail', True))
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return self.serializer_class(*args, **kwargs)
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend
|
DjangoFilterBackend
|
||||||
]
|
]
|
||||||
@ -1626,8 +1640,9 @@ class BomFilter(rest_filters.FilterSet):
|
|||||||
"""Custom filters for the BOM list."""
|
"""Custom filters for the BOM list."""
|
||||||
|
|
||||||
# Boolean filters for BOM item
|
# Boolean filters for BOM item
|
||||||
optional = rest_filters.BooleanFilter(label='BOM line is optional')
|
optional = rest_filters.BooleanFilter(label='BOM item is optional')
|
||||||
inherited = rest_filters.BooleanFilter(label='BOM line is inherited')
|
consumable = rest_filters.BooleanFilter(label='BOM item is consumable')
|
||||||
|
inherited = rest_filters.BooleanFilter(label='BOM item is inherited')
|
||||||
allow_variants = rest_filters.BooleanFilter(label='Variants are allowed')
|
allow_variants = rest_filters.BooleanFilter(label='Variants are allowed')
|
||||||
|
|
||||||
# Filters for linked 'part'
|
# Filters for linked 'part'
|
||||||
|
18
InvenTree/part/migrations/0087_bomitem_consumable.py
Normal file
18
InvenTree/part/migrations/0087_bomitem_consumable.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-04-28 00:08
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('part', '0086_auto_20220912_0007'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='bomitem',
|
||||||
|
name='consumable',
|
||||||
|
field=models.BooleanField(default=False, help_text='This BOM item is consumable (it is not tracked in build orders)', verbose_name='Consumable'),
|
||||||
|
),
|
||||||
|
]
|
@ -1134,7 +1134,12 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
|||||||
total = None
|
total = None
|
||||||
|
|
||||||
# Prefetch related tables, to reduce query expense
|
# Prefetch related tables, to reduce query expense
|
||||||
queryset = self.get_bom_items().prefetch_related(
|
queryset = self.get_bom_items()
|
||||||
|
|
||||||
|
# Ignore 'consumable' BOM items for this calculation
|
||||||
|
queryset = queryset.filter(consumable=False)
|
||||||
|
|
||||||
|
queryset = queryset.prefetch_related(
|
||||||
'sub_part__stock_items',
|
'sub_part__stock_items',
|
||||||
'sub_part__stock_items__allocations',
|
'sub_part__stock_items__allocations',
|
||||||
'sub_part__stock_items__sales_order_allocations',
|
'sub_part__stock_items__sales_order_allocations',
|
||||||
@ -2526,6 +2531,7 @@ class BomItem(DataImportMixin, models.Model):
|
|||||||
sub_part: Link to the child part (the part that will be consumed)
|
sub_part: Link to the child part (the part that will be consumed)
|
||||||
quantity: Number of 'sub_parts' consumed to produce one 'part'
|
quantity: Number of 'sub_parts' consumed to produce one 'part'
|
||||||
optional: Boolean field describing if this BomItem is optional
|
optional: Boolean field describing if this BomItem is optional
|
||||||
|
consumable: Boolean field describing if this BomItem is considered a 'consumable'
|
||||||
reference: BOM reference field (e.g. part designators)
|
reference: BOM reference field (e.g. part designators)
|
||||||
overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%')
|
overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%')
|
||||||
note: Note field for this BOM item
|
note: Note field for this BOM item
|
||||||
@ -2544,6 +2550,7 @@ class BomItem(DataImportMixin, models.Model):
|
|||||||
'allow_variants': {},
|
'allow_variants': {},
|
||||||
'inherited': {},
|
'inherited': {},
|
||||||
'optional': {},
|
'optional': {},
|
||||||
|
'consumable': {},
|
||||||
'note': {},
|
'note': {},
|
||||||
'part': {
|
'part': {
|
||||||
'label': _('Part'),
|
'label': _('Part'),
|
||||||
@ -2649,7 +2656,17 @@ class BomItem(DataImportMixin, models.Model):
|
|||||||
# Quantity required
|
# Quantity required
|
||||||
quantity = models.DecimalField(default=1.0, max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], verbose_name=_('Quantity'), help_text=_('BOM quantity for this BOM item'))
|
quantity = models.DecimalField(default=1.0, max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], verbose_name=_('Quantity'), help_text=_('BOM quantity for this BOM item'))
|
||||||
|
|
||||||
optional = models.BooleanField(default=False, verbose_name=_('Optional'), help_text=_("This BOM item is optional"))
|
optional = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_('Optional'),
|
||||||
|
help_text=_("This BOM item is optional")
|
||||||
|
)
|
||||||
|
|
||||||
|
consumable = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_('Consumable'),
|
||||||
|
help_text=_("This BOM item is consumable (it is not tracked in build orders)")
|
||||||
|
)
|
||||||
|
|
||||||
overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage],
|
overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage],
|
||||||
verbose_name=_('Overage'),
|
verbose_name=_('Overage'),
|
||||||
@ -2698,6 +2715,14 @@ class BomItem(DataImportMixin, models.Model):
|
|||||||
result_hash.update(str(self.optional).encode())
|
result_hash.update(str(self.optional).encode())
|
||||||
result_hash.update(str(self.inherited).encode())
|
result_hash.update(str(self.inherited).encode())
|
||||||
|
|
||||||
|
# Optionally encoded for backwards compatibility
|
||||||
|
if self.consumable:
|
||||||
|
result_hash.update(str(self.consumable).encode())
|
||||||
|
|
||||||
|
# Optionally encoded for backwards compatibility
|
||||||
|
if self.allow_variants:
|
||||||
|
result_hash.update(str(self.allow_variants).encode())
|
||||||
|
|
||||||
return str(result_hash.digest())
|
return str(result_hash.digest())
|
||||||
|
|
||||||
def validate_hash(self, valid=True):
|
def validate_hash(self, valid=True):
|
||||||
|
@ -247,6 +247,19 @@ class PartParameterTemplateSerializer(InvenTreeModelSerializer):
|
|||||||
class PartParameterSerializer(InvenTreeModelSerializer):
|
class PartParameterSerializer(InvenTreeModelSerializer):
|
||||||
"""JSON serializers for the PartParameter model."""
|
"""JSON serializers for the PartParameter model."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Custom initialization method for the serializer.
|
||||||
|
|
||||||
|
Allows us to optionally include or exclude particular information
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_detail = kwargs.pop('template_detail', False)
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if not template_detail:
|
||||||
|
self.fields.pop('template_detail')
|
||||||
|
|
||||||
template_detail = PartParameterTemplateSerializer(source='template', many=False, read_only=True)
|
template_detail = PartParameterTemplateSerializer(source='template', many=False, read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -747,6 +760,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
'inherited',
|
'inherited',
|
||||||
'note',
|
'note',
|
||||||
'optional',
|
'optional',
|
||||||
|
'consumable',
|
||||||
'overage',
|
'overage',
|
||||||
'pk',
|
'pk',
|
||||||
'part',
|
'part',
|
||||||
|
@ -813,7 +813,6 @@
|
|||||||
onPanelLoad("part-parameters", function() {
|
onPanelLoad("part-parameters", function() {
|
||||||
loadPartParameterTable(
|
loadPartParameterTable(
|
||||||
'#parameter-table',
|
'#parameter-table',
|
||||||
'{% url "api-part-parameter-list" %}',
|
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
part: {{ part.pk }},
|
part: {{ part.pk }},
|
||||||
|
@ -118,6 +118,7 @@ class BomExportTest(InvenTreeTestCase):
|
|||||||
'sub_assembly',
|
'sub_assembly',
|
||||||
'quantity',
|
'quantity',
|
||||||
'optional',
|
'optional',
|
||||||
|
'consumable',
|
||||||
'overage',
|
'overage',
|
||||||
'reference',
|
'reference',
|
||||||
'note',
|
'note',
|
||||||
|
@ -7,6 +7,8 @@ import django.core.exceptions as django_exceptions
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
|
import stock.models
|
||||||
|
|
||||||
from .models import BomItem, BomItemSubstitute, Part
|
from .models import BomItem, BomItemSubstitute, Part
|
||||||
|
|
||||||
|
|
||||||
@ -197,3 +199,49 @@ class BomItemTest(TestCase):
|
|||||||
|
|
||||||
# The substitution links should have been automatically removed
|
# The substitution links should have been automatically removed
|
||||||
self.assertEqual(bom_item.substitutes.count(), 0)
|
self.assertEqual(bom_item.substitutes.count(), 0)
|
||||||
|
|
||||||
|
def test_consumable(self):
|
||||||
|
"""Tests for the 'consumable' BomItem field"""
|
||||||
|
|
||||||
|
# Create an assembly part
|
||||||
|
assembly = Part.objects.create(name="An assembly", description="Made with parts", assembly=True)
|
||||||
|
|
||||||
|
# No BOM information initially
|
||||||
|
self.assertEqual(assembly.can_build, 0)
|
||||||
|
|
||||||
|
# Create some component items
|
||||||
|
c1 = Part.objects.create(name="C1", description="C1")
|
||||||
|
c2 = Part.objects.create(name="C2", description="C2")
|
||||||
|
c3 = Part.objects.create(name="C3", description="C3")
|
||||||
|
c4 = Part.objects.create(name="C4", description="C4")
|
||||||
|
|
||||||
|
for p in [c1, c2, c3, c4]:
|
||||||
|
# Ensure we have stock
|
||||||
|
stock.models.StockItem.objects.create(part=p, quantity=1000)
|
||||||
|
|
||||||
|
# Create some BOM items
|
||||||
|
BomItem.objects.create(
|
||||||
|
part=assembly,
|
||||||
|
sub_part=c1,
|
||||||
|
quantity=10
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(assembly.can_build, 100)
|
||||||
|
|
||||||
|
BomItem.objects.create(
|
||||||
|
part=assembly,
|
||||||
|
sub_part=c2,
|
||||||
|
quantity=50,
|
||||||
|
consumable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# A 'consumable' BomItem does not alter the can_build calculation
|
||||||
|
self.assertEqual(assembly.can_build, 100)
|
||||||
|
|
||||||
|
BomItem.objects.create(
|
||||||
|
part=assembly,
|
||||||
|
sub_part=c3,
|
||||||
|
quantity=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(assembly.can_build, 20)
|
||||||
|
17
InvenTree/stock/migrations/0088_remove_stockitem_infinite.py
Normal file
17
InvenTree/stock/migrations/0088_remove_stockitem_infinite.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 3.2.15 on 2022-09-22 02:23
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stock', '0087_auto_20220912_2341'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='stockitem',
|
||||||
|
name='infinite',
|
||||||
|
),
|
||||||
|
]
|
@ -224,7 +224,6 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
|||||||
build: Link to a Build (if this stock item was created from a build)
|
build: Link to a Build (if this stock item was created from a build)
|
||||||
is_building: Boolean field indicating if this stock item is currently being built (or is "in production")
|
is_building: Boolean field indicating if this stock item is currently being built (or is "in production")
|
||||||
purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder)
|
purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder)
|
||||||
infinite: If True this StockItem can never be exhausted
|
|
||||||
sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder)
|
sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder)
|
||||||
purchase_price: The unit purchase price for this StockItem - this is the unit price at time of purchase (if this item was purchased from an external supplier)
|
purchase_price: The unit purchase price for this StockItem - this is the unit price at time of purchase (if this item was purchased from an external supplier)
|
||||||
packaging: Description of how the StockItem is packaged (e.g. "reel", "loose", "tape" etc)
|
packaging: Description of how the StockItem is packaged (e.g. "reel", "loose", "tape" etc)
|
||||||
@ -882,11 +881,6 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
|||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
# If stock item is incoming, an (optional) ETA field
|
|
||||||
# expected_arrival = models.DateField(null=True, blank=True)
|
|
||||||
|
|
||||||
infinite = models.BooleanField(default=False)
|
|
||||||
|
|
||||||
def is_allocated(self):
|
def is_allocated(self):
|
||||||
"""Return True if this StockItem is allocated to a SalesOrder or a Build."""
|
"""Return True if this StockItem is allocated to a SalesOrder or a Build."""
|
||||||
# TODO - For now this only checks if the StockItem is allocated to a SalesOrder
|
# TODO - For now this only checks if the StockItem is allocated to a SalesOrder
|
||||||
@ -1565,7 +1559,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
|||||||
except InvalidOperation:
|
except InvalidOperation:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if count < 0 or self.infinite:
|
if count < 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.stocktake_date = datetime.now().date()
|
self.stocktake_date = datetime.now().date()
|
||||||
@ -1601,7 +1595,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Ignore amounts that do not make sense
|
# Ignore amounts that do not make sense
|
||||||
if quantity <= 0 or self.infinite:
|
if quantity <= 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if self.updateQuantity(self.quantity + quantity):
|
if self.updateQuantity(self.quantity + quantity):
|
||||||
@ -1630,7 +1624,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
|||||||
except InvalidOperation:
|
except InvalidOperation:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if quantity <= 0 or self.infinite:
|
if quantity <= 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if self.updateQuantity(self.quantity - quantity):
|
if self.updateQuantity(self.quantity - quantity):
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
{% include "InvenTree/settings/setting.html" with key="FORMS_CLOSE_USING_ESCAPE" icon="fa-window-close" user_setting=True %}
|
{% include "InvenTree/settings/setting.html" with key="FORMS_CLOSE_USING_ESCAPE" icon="fa-window-close" user_setting=True %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" user_setting=True %}
|
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" user_setting=True %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="DISPLAY_SCHEDULE_TAB" icon="fa-calendar-alt" user_setting=True %}
|
{% include "InvenTree/settings/setting.html" with key="DISPLAY_SCHEDULE_TAB" icon="fa-calendar-alt" user_setting=True %}
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="TABLE_STRING_MAX_LENGTH" icon="fa-table" user_setting=True %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
@ -154,7 +154,7 @@ function enableBreadcrumbTree(options) {
|
|||||||
var label = options.label;
|
var label = options.label;
|
||||||
|
|
||||||
if (!label) {
|
if (!label) {
|
||||||
console.log('ERROR: enableBreadcrumbTree called without supplying label');
|
console.error('enableBreadcrumbTree called without supplying label');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -382,6 +382,7 @@ function bomItemFields() {
|
|||||||
note: {},
|
note: {},
|
||||||
allow_variants: {},
|
allow_variants: {},
|
||||||
inherited: {},
|
inherited: {},
|
||||||
|
consumable: {},
|
||||||
optional: {},
|
optional: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -761,7 +762,22 @@ function loadBomTable(table, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return available;
|
return available;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canBuildQuantity(row) {
|
||||||
|
// Calculate how many of each row we can make, given current stock
|
||||||
|
|
||||||
|
if (row.consumable) {
|
||||||
|
// If the row is "consumable" we do not 'track' the quantity
|
||||||
|
return Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent div-by-zero or negative errors
|
||||||
|
if ((row.quantity || 0) <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableQuantity(row) / row.quantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct the table columns
|
// Construct the table columns
|
||||||
@ -796,7 +812,7 @@ function loadBomTable(table, options={}) {
|
|||||||
// Part column
|
// Part column
|
||||||
cols.push(
|
cols.push(
|
||||||
{
|
{
|
||||||
field: 'sub_part_detail.full_name',
|
field: 'sub_part',
|
||||||
title: '{% trans "Part" %}',
|
title: '{% trans "Part" %}',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
switchable: false,
|
switchable: false,
|
||||||
@ -844,6 +860,9 @@ function loadBomTable(table, options={}) {
|
|||||||
{
|
{
|
||||||
field: 'sub_part_detail.description',
|
field: 'sub_part_detail.description',
|
||||||
title: '{% trans "Description" %}',
|
title: '{% trans "Description" %}',
|
||||||
|
formatter: function(value) {
|
||||||
|
return withTitle(shortenString(value), value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -872,8 +891,12 @@ function loadBomTable(table, options={}) {
|
|||||||
text += ` <small>${row.sub_part_detail.units}</small>`;
|
text += ` <small>${row.sub_part_detail.units}</small>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (row.consumable) {
|
||||||
|
text += ` <small>({% trans "Consumable" %})</small>`;
|
||||||
|
}
|
||||||
|
|
||||||
if (row.optional) {
|
if (row.optional) {
|
||||||
text += ' ({% trans "Optional" %})';
|
text += ' <small>({% trans "Optional" %})</small>';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.overage) {
|
if (row.overage) {
|
||||||
@ -966,40 +989,11 @@ function loadBomTable(table, options={}) {
|
|||||||
if (row.substitutes && row.substitutes.length > 0) {
|
if (row.substitutes && row.substitutes.length > 0) {
|
||||||
return row.substitutes.length;
|
return row.substitutes.length;
|
||||||
} else {
|
} else {
|
||||||
return `-`;
|
return yesNoLabel(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (show_pricing) {
|
|
||||||
cols.push({
|
|
||||||
field: 'purchase_price_range',
|
|
||||||
title: '{% trans "Purchase Price Range" %}',
|
|
||||||
searchable: false,
|
|
||||||
sortable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
cols.push({
|
|
||||||
field: 'purchase_price_avg',
|
|
||||||
title: '{% trans "Purchase Price Average" %}',
|
|
||||||
searchable: false,
|
|
||||||
sortable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
cols.push({
|
|
||||||
field: 'price_range',
|
|
||||||
title: '{% trans "Supplier Cost" %}',
|
|
||||||
sortable: true,
|
|
||||||
formatter: function(value) {
|
|
||||||
if (value) {
|
|
||||||
return value;
|
|
||||||
} else {
|
|
||||||
return `<span class='warning-msg'>{% trans 'No supplier pricing available' %}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cols.push({
|
cols.push({
|
||||||
field: 'optional',
|
field: 'optional',
|
||||||
title: '{% trans "Optional" %}',
|
title: '{% trans "Optional" %}',
|
||||||
@ -1009,6 +1003,15 @@ function loadBomTable(table, options={}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cols.push({
|
||||||
|
field: 'consumable',
|
||||||
|
title: '{% trans "Consumable" %}',
|
||||||
|
searchable: false,
|
||||||
|
formatter: function(value) {
|
||||||
|
return yesNoLabel(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
cols.push({
|
cols.push({
|
||||||
field: 'allow_variants',
|
field: 'allow_variants',
|
||||||
title: '{% trans "Allow Variants" %}',
|
title: '{% trans "Allow Variants" %}',
|
||||||
@ -1037,36 +1040,63 @@ function loadBomTable(table, options={}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (show_pricing) {
|
||||||
|
cols.push({
|
||||||
|
field: 'purchase_price_range',
|
||||||
|
title: '{% trans "Purchase Price Range" %}',
|
||||||
|
searchable: false,
|
||||||
|
sortable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
cols.push({
|
||||||
|
field: 'purchase_price_avg',
|
||||||
|
title: '{% trans "Purchase Price Average" %}',
|
||||||
|
searchable: false,
|
||||||
|
sortable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
cols.push({
|
||||||
|
field: 'price_range',
|
||||||
|
title: '{% trans "Supplier Cost" %}',
|
||||||
|
sortable: true,
|
||||||
|
formatter: function(value) {
|
||||||
|
if (value) {
|
||||||
|
return value;
|
||||||
|
} else {
|
||||||
|
return `<span class='warning-msg'>{% trans 'No supplier pricing available' %}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
cols.push(
|
cols.push(
|
||||||
{
|
{
|
||||||
field: 'can_build',
|
field: 'can_build',
|
||||||
title: '{% trans "Can Build" %}',
|
title: '{% trans "Can Build" %}',
|
||||||
|
sortable: true,
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
var can_build = 0;
|
|
||||||
|
|
||||||
var available = availableQuantity(row);
|
// "Consumable" parts are not tracked in the build
|
||||||
|
if (row.consumable) {
|
||||||
if (row.quantity > 0) {
|
return `<em>{% trans "Consumable item" %}</em>`;
|
||||||
can_build = available / row.quantity;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var text = formatDecimal(can_build, 2);
|
var can_build = canBuildQuantity(row);
|
||||||
|
|
||||||
// Take "on order" quantity into account
|
return +can_build.toFixed(2);
|
||||||
if (row.on_order && row.on_order > 0 && row.quantity > 0) {
|
},
|
||||||
available += row.on_order;
|
sorter: function(valA, valB, rowA, rowB) {
|
||||||
can_build = available / row.quantity;
|
// Function to sort the "can build" quantity
|
||||||
|
var cb_a = canBuildQuantity(rowA);
|
||||||
|
var cb_b = canBuildQuantity(rowB);
|
||||||
|
|
||||||
text += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Including On Order" %}: ${formatDecimal(can_build, 2)}'></span>`;
|
return (cb_a > cb_b) ? 1 : -1;
|
||||||
}
|
|
||||||
|
|
||||||
return text;
|
|
||||||
},
|
},
|
||||||
footerFormatter: function(data) {
|
footerFormatter: function(data) {
|
||||||
var can_build = null;
|
var can_build = null;
|
||||||
|
|
||||||
data.forEach(function(row) {
|
data.forEach(function(row) {
|
||||||
if (row.part == options.parent_id && row.quantity > 0) {
|
if (row.quantity > 0 && !row.consumable) {
|
||||||
var cb = availableQuantity(row) / row.quantity;
|
var cb = availableQuantity(row) / row.quantity;
|
||||||
|
|
||||||
if (can_build == null || cb < can_build) {
|
if (can_build == null || cb < can_build) {
|
||||||
@ -1080,23 +1110,7 @@ function loadBomTable(table, options={}) {
|
|||||||
} else {
|
} else {
|
||||||
return formatDecimal(can_build, 2);
|
return formatDecimal(can_build, 2);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
sorter: function(valA, valB, rowA, rowB) {
|
|
||||||
// Function to sort the "can build" quantity
|
|
||||||
var cb_a = 0;
|
|
||||||
var cb_b = 0;
|
|
||||||
|
|
||||||
if (rowA.quantity > 0) {
|
|
||||||
cb_a = availableQuantity(rowA) / rowA.quantity;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rowB.quantity > 0) {
|
|
||||||
cb_b = availableQuantity(rowB) / rowB.quantity;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (cb_a > cb_b) ? 1 : -1;
|
|
||||||
},
|
|
||||||
sortable: true,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1107,6 +1121,9 @@ function loadBomTable(table, options={}) {
|
|||||||
title: '{% trans "Notes" %}',
|
title: '{% trans "Notes" %}',
|
||||||
searchable: true,
|
searchable: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
formatter: function(value) {
|
||||||
|
return withTitle(shortenString(value), value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1177,12 +1194,15 @@ function loadBomTable(table, options={}) {
|
|||||||
response[idx].parentId = bom_pk;
|
response[idx].parentId = bom_pk;
|
||||||
}
|
}
|
||||||
|
|
||||||
var row = $(table).bootstrapTable('getRowByUniqueId', bom_pk);
|
var row = table.bootstrapTable('getRowByUniqueId', bom_pk);
|
||||||
row.sub_assembly_received = true;
|
row.sub_assembly_received = true;
|
||||||
|
|
||||||
$(table).bootstrapTable('updateByUniqueId', bom_pk, row, true);
|
table.bootstrapTable('updateByUniqueId', bom_pk, row, true);
|
||||||
|
|
||||||
table.bootstrapTable('append', response);
|
table.bootstrapTable('append', response);
|
||||||
|
|
||||||
|
// Auto-expand the newly added row
|
||||||
|
$(`.treegrid-${bom_pk}`).treegrid('expand');
|
||||||
},
|
},
|
||||||
error: function(xhr) {
|
error: function(xhr) {
|
||||||
console.error('Error requesting BOM for part=' + part_pk);
|
console.error('Error requesting BOM for part=' + part_pk);
|
||||||
@ -1235,28 +1255,39 @@ function loadBomTable(table, options={}) {
|
|||||||
|
|
||||||
table.treegrid({
|
table.treegrid({
|
||||||
treeColumn: 1,
|
treeColumn: 1,
|
||||||
onExpand: function() {
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
table.treegrid('collapseAll');
|
table.treegrid('collapseAll');
|
||||||
|
|
||||||
// Callback for 'load sub assembly' button
|
// Callback for 'load sub assembly' button
|
||||||
$(table).find('.load-sub-assembly').click(function(event) {
|
table.find('.load-sub-assembly').click(function(event) {
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
var pk = $(this).attr('pk');
|
var pk = $(this).attr('pk');
|
||||||
var row = $(table).bootstrapTable('getRowByUniqueId', pk);
|
var row = table.bootstrapTable('getRowByUniqueId', pk);
|
||||||
|
|
||||||
// Request BOM data for this subassembly
|
// Request BOM data for this subassembly
|
||||||
requestSubItems(row.pk, row.sub_part);
|
requestSubItems(row.pk, row.sub_part);
|
||||||
|
|
||||||
row.sub_assembly_requested = true;
|
row.sub_assembly_requested = true;
|
||||||
$(table).bootstrapTable('updateByUniqueId', pk, row, true);
|
table.bootstrapTable('updateByUniqueId', pk, row, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var data = table.bootstrapTable('getData');
|
||||||
|
|
||||||
|
for (var idx = 0; idx < data.length; idx++) {
|
||||||
|
var row = data[idx];
|
||||||
|
|
||||||
|
if (!row.parentId) {
|
||||||
|
row.parentId = parent_id;
|
||||||
|
|
||||||
|
table.bootstrapTable('updateByUniqueId', row.pk, row, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onLoadSuccess: function() {
|
onLoadSuccess: function(data) {
|
||||||
|
|
||||||
if (options.editable) {
|
if (options.editable) {
|
||||||
table.bootstrapTable('uncheckAll');
|
table.bootstrapTable('uncheckAll');
|
||||||
}
|
}
|
||||||
|
@ -201,58 +201,74 @@ function cancelBuildOrder(build_id, options={}) {
|
|||||||
/* Construct a form to "complete" (finish) a build order */
|
/* Construct a form to "complete" (finish) a build order */
|
||||||
function completeBuildOrder(build_id, options={}) {
|
function completeBuildOrder(build_id, options={}) {
|
||||||
|
|
||||||
var url = `/api/build/${build_id}/finish/`;
|
constructForm(`/api/build/${build_id}/finish/`, {
|
||||||
|
fieldsFunction: function(opts) {
|
||||||
|
var ctx = opts.context || {};
|
||||||
|
|
||||||
var fields = {
|
var fields = {
|
||||||
accept_unallocated: {},
|
accept_unallocated: {},
|
||||||
accept_overallocated: {},
|
accept_overallocated: {},
|
||||||
accept_incomplete: {},
|
accept_incomplete: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
var html = '';
|
// Hide "accept overallocated" field if the build is *not* overallocated
|
||||||
|
if (!ctx.overallocated) {
|
||||||
|
delete fields.accept_overallocated;
|
||||||
|
}
|
||||||
|
|
||||||
if (options.allocated && options.completed) {
|
// Hide "accept incomplete" field if the build has been completed
|
||||||
html += `
|
if (!ctx.remaining || ctx.remaining == 0) {
|
||||||
<div class='alert alert-block alert-success'>
|
delete fields.accept_incomplete;
|
||||||
{% trans "Build order is ready to be completed" %}
|
}
|
||||||
</div>`;
|
|
||||||
} else {
|
|
||||||
html += `
|
|
||||||
<div class='alert alert-block alert-danger'>
|
|
||||||
<strong>{% trans "Build Order is incomplete" %}</strong>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (!options.allocated) {
|
// Hide "accept unallocated" field if the build is fully allocated
|
||||||
html += `<div class='alert alert-block alert-warning'>{% trans "Required stock has not been fully allocated" %}</div>`;
|
if (ctx.allocated) {
|
||||||
}
|
delete fields.accept_unallocated;
|
||||||
|
}
|
||||||
|
|
||||||
if (!options.completed) {
|
return fields;
|
||||||
html += `<div class='alert alert-block alert-warning'>{% trans "Required build quantity has not been completed" %}</div>`;
|
},
|
||||||
}
|
preFormContent: function(opts) {
|
||||||
}
|
var ctx = opts.context || {};
|
||||||
|
|
||||||
// Hide particular fields if they are not required
|
var html = '';
|
||||||
|
|
||||||
if (options.allocated) {
|
if (ctx.allocated && ctx.remaining == 0 && ctx.incomplete == 0) {
|
||||||
delete fields.accept_unallocated;
|
html += `
|
||||||
}
|
<div class='alert alert-block alert-success'>
|
||||||
|
{% trans "Build order is ready to be completed" %}'
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
|
||||||
if (options.completed) {
|
if (ctx.incomplete > 0) {
|
||||||
delete fields.accept_incomplete;
|
html += `
|
||||||
}
|
<div class='alert alert-block alert-danger'>
|
||||||
|
<strong>{% trans "Build order has incomplete outputs" %}</strong><br>
|
||||||
|
{% trans "This build order cannot be completed as there are incomplete outputs" %}
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
html += `
|
||||||
|
<div class='alert alert-block alert-danger'>
|
||||||
|
<strong>{% trans "Build Order is incomplete" %}</strong>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
if (!options.overallocated) {
|
if (!ctx.allocated) {
|
||||||
delete fields.accept_overallocated;
|
html += `<div class='alert alert-block alert-warning'>{% trans "Required stock has not been fully allocated" %}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructForm(url, {
|
if (ctx.remaining > 0) {
|
||||||
fields: fields,
|
html += `<div class='alert alert-block alert-warning'>{% trans "Required build quantity has not been completed" %}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
|
},
|
||||||
reload: true,
|
reload: true,
|
||||||
confirm: true,
|
confirm: true,
|
||||||
method: 'POST',
|
|
||||||
title: '{% trans "Complete Build Order" %}',
|
title: '{% trans "Complete Build Order" %}',
|
||||||
preFormContent: html,
|
method: 'POST',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1843,33 +1859,37 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
|
|
||||||
var icons = '';
|
var icons = '';
|
||||||
|
|
||||||
if (available_stock < (required - allocated)) {
|
if (row.consumable) {
|
||||||
icons += `<span class='fas fa-times-circle icon-red float-right' title='{% trans "Insufficient stock available" %}'></span>`;
|
icons += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Consumable item" %}'></span>`;
|
||||||
} else {
|
} else {
|
||||||
icons += `<span class='fas fa-check-circle icon-green float-right' title='{% trans "Sufficient stock available" %}'></span>`;
|
if (available_stock < (required - allocated)) {
|
||||||
|
icons += `<span class='fas fa-times-circle icon-red float-right' title='{% trans "Insufficient stock available" %}'></span>`;
|
||||||
|
} else {
|
||||||
|
icons += `<span class='fas fa-check-circle icon-green float-right' title='{% trans "Sufficient stock available" %}'></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (available_stock <= 0) {
|
||||||
|
icons += `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
|
||||||
|
} else {
|
||||||
|
var extra = '';
|
||||||
|
if ((substitute_stock > 0) && (variant_stock > 0)) {
|
||||||
|
extra = '{% trans "Includes variant and substitute stock" %}';
|
||||||
|
} else if (variant_stock > 0) {
|
||||||
|
extra = '{% trans "Includes variant stock" %}';
|
||||||
|
} else if (substitute_stock > 0) {
|
||||||
|
extra = '{% trans "Includes substitute stock" %}';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extra) {
|
||||||
|
icons += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.on_order && row.on_order > 0) {
|
if (row.on_order && row.on_order > 0) {
|
||||||
icons += `<span class='fas fa-shopping-cart float-right' title='{% trans "On Order" %}: ${row.on_order}'></span>`;
|
icons += `<span class='fas fa-shopping-cart float-right' title='{% trans "On Order" %}: ${row.on_order}'></span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (available_stock <= 0) {
|
|
||||||
icons += `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
|
|
||||||
} else {
|
|
||||||
var extra = '';
|
|
||||||
if ((substitute_stock > 0) && (variant_stock > 0)) {
|
|
||||||
extra = '{% trans "Includes variant and substitute stock" %}';
|
|
||||||
} else if (variant_stock > 0) {
|
|
||||||
extra = '{% trans "Includes variant stock" %}';
|
|
||||||
} else if (substitute_stock > 0) {
|
|
||||||
extra = '{% trans "Includes substitute stock" %}';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (extra) {
|
|
||||||
icons += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return renderLink(text, url) + icons;
|
return renderLink(text, url) + icons;
|
||||||
},
|
},
|
||||||
sorter: function(valA, valB, rowA, rowB) {
|
sorter: function(valA, valB, rowA, rowB) {
|
||||||
@ -1882,8 +1902,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
title: '{% trans "Allocated" %}',
|
title: '{% trans "Allocated" %}',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
var allocated = allocatedQuantity(row);
|
|
||||||
var required = requiredQuantity(row);
|
var required = requiredQuantity(row);
|
||||||
|
var allocated = row.consumable ? required : allocatedQuantity(row);
|
||||||
return makeProgressBar(allocated, required);
|
return makeProgressBar(allocated, required);
|
||||||
},
|
},
|
||||||
sorter: function(valA, valB, rowA, rowB) {
|
sorter: function(valA, valB, rowA, rowB) {
|
||||||
@ -1922,6 +1942,11 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
field: 'actions',
|
field: 'actions',
|
||||||
title: '{% trans "Actions" %}',
|
title: '{% trans "Actions" %}',
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
|
|
||||||
|
if (row.consumable) {
|
||||||
|
return `<em>{% trans "Consumable item" %}</em>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Generate action buttons for this build output
|
// Generate action buttons for this build output
|
||||||
var html = `<div class='btn-group float-right' role='group'>`;
|
var html = `<div class='btn-group float-right' role='group'>`;
|
||||||
|
|
||||||
@ -2077,6 +2102,11 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
|
|||||||
for (var idx = 0; idx < bom_items.length; idx++) {
|
for (var idx = 0; idx < bom_items.length; idx++) {
|
||||||
var bom_item = bom_items[idx];
|
var bom_item = bom_items[idx];
|
||||||
|
|
||||||
|
// Ignore "consumable" BOM items
|
||||||
|
if (bom_item.consumable) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var required = bom_item.required || 0;
|
var required = bom_item.required || 0;
|
||||||
var allocated = bom_item.allocated || 0;
|
var allocated = bom_item.allocated || 0;
|
||||||
var remaining = required - allocated;
|
var remaining = required - allocated;
|
||||||
|
@ -45,10 +45,11 @@ function deleteButton(url, text='{% trans "Delete" %}') {
|
|||||||
*/
|
*/
|
||||||
function shortenString(input_string, options={}) {
|
function shortenString(input_string, options={}) {
|
||||||
|
|
||||||
var max_length = options.max_length || 100;
|
// Maximum length can be provided via options argument, or via a user-configurable setting
|
||||||
|
var max_length = options.max_length || user_settings.TABLE_STRING_MAX_LENGTH;
|
||||||
|
|
||||||
if (input_string == null) {
|
if (!max_length || !input_string) {
|
||||||
return null;
|
return input_string;
|
||||||
}
|
}
|
||||||
|
|
||||||
input_string = input_string.toString();
|
input_string = input_string.toString();
|
||||||
|
@ -803,7 +803,7 @@ function loadSimplePartTable(table, url, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function loadPartParameterTable(table, url, options) {
|
function loadPartParameterTable(table, options) {
|
||||||
|
|
||||||
var params = options.params || {};
|
var params = options.params || {};
|
||||||
|
|
||||||
@ -819,7 +819,7 @@ function loadPartParameterTable(table, url, options) {
|
|||||||
setupFilterList('part-parameters', $(table), filterTarget);
|
setupFilterList('part-parameters', $(table), filterTarget);
|
||||||
|
|
||||||
$(table).inventreeTable({
|
$(table).inventreeTable({
|
||||||
url: url,
|
url: '{% url "api-part-parameter-list" %}',
|
||||||
original: params,
|
original: params,
|
||||||
queryParams: filters,
|
queryParams: filters,
|
||||||
name: 'partparameters',
|
name: 'partparameters',
|
||||||
@ -1292,13 +1292,12 @@ function loadParametricPartTable(table, options={}) {
|
|||||||
},
|
},
|
||||||
columns: columns,
|
columns: columns,
|
||||||
showColumns: true,
|
showColumns: true,
|
||||||
// filterControl: true,
|
|
||||||
sidePagination: 'server',
|
sidePagination: 'server',
|
||||||
idField: 'pk',
|
idField: 'pk',
|
||||||
uniqueId: 'pk',
|
uniqueId: 'pk',
|
||||||
onLoadSuccess: function() {
|
onLoadSuccess: function(response) {
|
||||||
|
|
||||||
var data = $(table).bootstrapTable('getData');
|
var data = response.results;
|
||||||
|
|
||||||
for (var idx = 0; idx < data.length; idx++) {
|
for (var idx = 0; idx < data.length; idx++) {
|
||||||
var row = data[idx];
|
var row = data[idx];
|
||||||
@ -1309,7 +1308,7 @@ function loadParametricPartTable(table, options={}) {
|
|||||||
row[`parameter_${parameter.template}`] = parameter.data;
|
row[`parameter_${parameter.template}`] = parameter.data;
|
||||||
});
|
});
|
||||||
|
|
||||||
$(table).bootstrapTable('updateRow', pk, row);
|
$(table).bootstrapTable('updateByUniqueId', pk, row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -79,6 +79,14 @@ function getAvailableTableFilters(tableKey) {
|
|||||||
type: 'bool',
|
type: 'bool',
|
||||||
title: '{% trans "Allow Variant Stock" %}',
|
title: '{% trans "Allow Variant Stock" %}',
|
||||||
},
|
},
|
||||||
|
optional: {
|
||||||
|
type: 'bool',
|
||||||
|
title: '{% trans "Optional" %}',
|
||||||
|
},
|
||||||
|
consumable: {
|
||||||
|
type: 'bool',
|
||||||
|
title: '{% trans "Consumable" %}',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user