Merge branch 'inventree:master' into trans-improv

This commit is contained in:
Matthias Mair 2021-08-05 08:17:21 +02:00 committed by GitHub
commit 96378cb556
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 616 additions and 1056 deletions

View File

@ -85,8 +85,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
""" """
def __init__(self, instance=None, data=empty, **kwargs): def __init__(self, instance=None, data=empty, **kwargs):
"""
# self.instance = instance Custom __init__ routine to ensure that *default* values (as specified in the ORM)
are used by the DRF serializers, *if* the values are not provided by the user.
"""
# If instance is None, we are creating a new instance # If instance is None, we are creating a new instance
if instance is None and data is not empty: if instance is None and data is not empty:
@ -193,7 +195,15 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
try: try:
instance.full_clean() instance.full_clean()
except (ValidationError, DjangoValidationError) as exc: except (ValidationError, DjangoValidationError) as exc:
raise ValidationError(detail=serializers.as_serializer_error(exc))
data = exc.message_dict
# Change '__all__' key (django style) to 'non_field_errors' (DRF style)
if '__all__' in data:
data['non_field_errors'] = data['__all__']
del data['__all__']
raise ValidationError(data)
return data return data

View File

@ -111,7 +111,7 @@ src="{% static 'img/blank_image.png' %}"
<li><a href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li> <li><a href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li>
{% endif %} {% endif %}
{% if build.status == BuildStatus.CANCELLED and roles.build.delete %} {% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
<li><a href='#' id='build-delete'><span class='fas fa-trash-alt'></span> {% trans "Delete Build"% }</a> <li><a href='#' id='build-delete'><span class='fas fa-trash-alt'></span> {% trans "Delete Build" %}</a>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>

View File

@ -637,7 +637,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'PART_PURCHASEABLE': { 'PART_PURCHASEABLE': {
'name': _('Purchaseable'), 'name': _('Purchaseable'),
'description': _('Parts are purchaseable by default'), 'description': _('Parts are purchaseable by default'),
'default': False, 'default': True,
'validator': bool, 'validator': bool,
}, },
@ -662,6 +662,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
# TODO: Remove this setting in future, new API forms make this not useful
'PART_SHOW_QUANTITY_IN_FORMS': { 'PART_SHOW_QUANTITY_IN_FORMS': {
'name': _('Show Quantity in Forms'), 'name': _('Show Quantity in Forms'),
'description': _('Display available part quantity in some forms'), 'description': _('Display available part quantity in some forms'),

View File

@ -23,6 +23,7 @@ from djmoney.money import Money
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.exceptions import MissingRate
from decimal import Decimal
from .models import Part, PartCategory, BomItem from .models import Part, PartCategory, BomItem
from .models import PartParameter, PartParameterTemplate from .models import PartParameter, PartParameterTemplate
@ -30,6 +31,7 @@ from .models import PartAttachment, PartTestTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak from .models import PartSellPriceBreak, PartInternalPriceBreak
from .models import PartCategoryParameterTemplate from .models import PartCategoryParameterTemplate
from stock.models import StockItem
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from build.models import Build from build.models import Build
@ -338,9 +340,7 @@ class PartThumbs(generics.ListAPIView):
- Images may be used for multiple parts! - Images may be used for multiple parts!
""" """
queryset = self.get_queryset() queryset = self.filter_queryset(self.get_queryset())
# TODO - We should return the thumbnails here, not the full image!
# Return the most popular parts first # Return the most popular parts first
data = queryset.values( data = queryset.values(
@ -349,6 +349,19 @@ class PartThumbs(generics.ListAPIView):
return Response(data) return Response(data)
filter_backends = [
filters.SearchFilter,
]
search_fields = [
'name',
'description',
'IPN',
'revision',
'keywords',
'category__name',
]
class PartThumbsUpdate(generics.RetrieveUpdateAPIView): class PartThumbsUpdate(generics.RetrieveUpdateAPIView):
""" API endpoint for updating Part thumbnails""" """ API endpoint for updating Part thumbnails"""
@ -443,6 +456,8 @@ class PartFilter(rest_filters.FilterSet):
else: else:
queryset = queryset.filter(IPN='') queryset = queryset.filter(IPN='')
return queryset
# Regex filter for name # Regex filter for name
name_regex = rest_filters.CharFilter(label='Filter by name (regex)', field_name='name', lookup_expr='iregex') name_regex = rest_filters.CharFilter(label='Filter by name (regex)', field_name='name', lookup_expr='iregex')
@ -615,17 +630,76 @@ class PartList(generics.ListCreateAPIView):
else: else:
return Response(data) return Response(data)
def perform_create(self, serializer): def create(self, request, *args, **kwargs):
""" """
We wish to save the user who created this part! We wish to save the user who created this part!
Note: Implementation copied from DRF class CreateModelMixin Note: Implementation copied from DRF class CreateModelMixin
""" """
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
part = serializer.save() part = serializer.save()
part.creation_user = self.request.user part.creation_user = self.request.user
# Optionally copy templates from category or parent category
copy_templates = {
'main': str2bool(request.data.get('copy_category_templates', False)),
'parent': str2bool(request.data.get('copy_parent_templates', False))
}
part.save(**{'add_category_templates': copy_templates})
# Optionally copy data from another part (e.g. when duplicating)
copy_from = request.data.get('copy_from', None)
if copy_from is not None:
try:
original = Part.objects.get(pk=copy_from)
copy_bom = str2bool(request.data.get('copy_bom', False))
copy_parameters = str2bool(request.data.get('copy_parameters', False))
copy_image = str2bool(request.data.get('copy_image', True))
# Copy image?
if copy_image:
part.image = original.image
part.save() part.save()
# Copy BOM?
if copy_bom:
part.copy_bom_from(original)
# Copy parameter data?
if copy_parameters:
part.copy_parameters_from(original)
except (ValueError, Part.DoesNotExist):
pass
# Optionally create initial stock item
try:
initial_stock = Decimal(request.data.get('initial_stock', 0))
if initial_stock > 0 and part.default_location is not None:
stock_item = StockItem(
part=part,
quantity=initial_stock,
location=part.default_location,
)
stock_item.save(user=request.user)
except:
pass
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)

View File

@ -18,7 +18,6 @@ import common.models
from common.forms import MatchItemForm from common.forms import MatchItemForm
from .models import Part, PartCategory, PartRelated from .models import Part, PartCategory, PartRelated
from .models import BomItem
from .models import PartParameterTemplate, PartParameter from .models import PartParameterTemplate, PartParameter
from .models import PartCategoryParameterTemplate from .models import PartCategoryParameterTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak from .models import PartSellPriceBreak, PartInternalPriceBreak
@ -178,82 +177,6 @@ class SetPartCategoryForm(forms.Form):
part_category = TreeNodeChoiceField(queryset=PartCategory.objects.all(), required=True, help_text=_('Select part category')) part_category = TreeNodeChoiceField(queryset=PartCategory.objects.all(), required=True, help_text=_('Select part category'))
class EditPartForm(HelperForm):
"""
Form for editing a Part object.
"""
field_prefix = {
'keywords': 'fa-key',
'link': 'fa-link',
'IPN': 'fa-hashtag',
'default_expiry': 'fa-stopwatch',
}
bom_copy = forms.BooleanField(required=False,
initial=True,
help_text=_("Duplicate all BOM data for this part"),
label=_('Copy BOM'),
widget=forms.HiddenInput())
parameters_copy = forms.BooleanField(required=False,
initial=True,
help_text=_("Duplicate all parameter data for this part"),
label=_('Copy Parameters'),
widget=forms.HiddenInput())
confirm_creation = forms.BooleanField(required=False,
initial=False,
help_text=_('Confirm part creation'),
widget=forms.HiddenInput())
selected_category_templates = forms.BooleanField(required=False,
initial=False,
label=_('Include category parameter templates'),
widget=forms.HiddenInput())
parent_category_templates = forms.BooleanField(required=False,
initial=False,
label=_('Include parent categories parameter templates'),
widget=forms.HiddenInput())
initial_stock = forms.IntegerField(required=False,
initial=0,
label=_('Initial stock amount'),
help_text=_('Create stock for this part'))
class Meta:
model = Part
fields = [
'confirm_creation',
'category',
'selected_category_templates',
'parent_category_templates',
'name',
'IPN',
'description',
'revision',
'bom_copy',
'parameters_copy',
'keywords',
'variant_of',
'link',
'default_location',
'default_supplier',
'default_expiry',
'units',
'minimum_stock',
'initial_stock',
'component',
'assembly',
'is_template',
'trackable',
'purchaseable',
'salable',
'virtual',
]
class EditPartParameterTemplateForm(HelperForm): class EditPartParameterTemplateForm(HelperForm):
""" Form for editing a PartParameterTemplate object """ """ Form for editing a PartParameterTemplate object """
@ -317,33 +240,6 @@ class EditCategoryParameterTemplateForm(HelperForm):
] ]
class EditBomItemForm(HelperForm):
""" Form for editing a BomItem object """
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
sub_part = PartModelChoiceField(queryset=Part.objects.all(), label=_('Sub part'))
class Meta:
model = BomItem
fields = [
'part',
'sub_part',
'quantity',
'reference',
'overage',
'note',
'allow_variants',
'inherited',
'optional',
]
# Prevent editing of the part associated with this BomItem
widgets = {
'part': forms.HiddenInput()
}
class PartPriceForm(forms.Form): class PartPriceForm(forms.Form):
""" Simple form for viewing part pricing information """ """ Simple form for viewing part pricing information """

View File

@ -34,7 +34,6 @@ from stdimage.models import StdImageField
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from datetime import datetime from datetime import datetime
from rapidfuzz import fuzz
import hashlib import hashlib
from InvenTree import helpers from InvenTree import helpers
@ -235,57 +234,6 @@ def rename_part_image(instance, filename):
return os.path.join(base, fname) return os.path.join(base, fname)
def match_part_names(match, threshold=80, reverse=True, compare_length=False):
""" Return a list of parts whose name matches the search term using fuzzy search.
Args:
match: Term to match against
threshold: Match percentage that must be exceeded (default = 65)
reverse: Ordering for search results (default = True - highest match is first)
compare_length: Include string length checks
Returns:
A sorted dict where each element contains the following key:value pairs:
- 'part' : The matched part
- 'ratio' : The matched ratio
"""
match = str(match).strip().lower()
if len(match) == 0:
return []
parts = Part.objects.all()
matches = []
for part in parts:
compare = str(part.name).strip().lower()
if len(compare) == 0:
continue
ratio = fuzz.partial_token_sort_ratio(compare, match)
if compare_length:
# Also employ primitive length comparison
# TODO - Improve this somewhat...
l_min = min(len(match), len(compare))
l_max = max(len(match), len(compare))
ratio *= (l_min / l_max)
if ratio >= threshold:
matches.append({
'part': part,
'ratio': round(ratio, 1)
})
matches = sorted(matches, key=lambda item: item['ratio'], reverse=reverse)
return matches
class PartManager(TreeManager): class PartManager(TreeManager):
""" """
Defines a custom object manager for the Part model. Defines a custom object manager for the Part model.
@ -409,7 +357,7 @@ class Part(MPTTModel):
""" """
# Get category templates settings # Get category templates settings
add_category_templates = kwargs.pop('add_category_templates', None) add_category_templates = kwargs.pop('add_category_templates', False)
if self.pk: if self.pk:
previous = Part.objects.get(pk=self.pk) previous = Part.objects.get(pk=self.pk)
@ -437,36 +385,26 @@ class Part(MPTTModel):
# Get part category # Get part category
category = self.category category = self.category
if category and add_category_templates: if category is not None:
# Store templates added to part
template_list = [] template_list = []
# Create part parameters for selected category parent_categories = category.get_ancestors(include_self=True)
category_templates = add_category_templates['main']
if category_templates:
for template in category.get_parameter_templates():
parameter = PartParameter.create(part=self,
template=template.parameter_template,
data=template.default_value,
save=True)
if parameter:
template_list.append(template.parameter_template)
# Create part parameters for parent category
category_templates = add_category_templates['parent']
if category_templates:
# Get parent categories
parent_categories = category.get_ancestors()
for category in parent_categories: for category in parent_categories:
for template in category.get_parameter_templates(): for template in category.get_parameter_templates():
# Check that template wasn't already added # Check that template wasn't already added
if template.parameter_template not in template_list: if template.parameter_template not in template_list:
template_list.append(template.parameter_template)
try: try:
PartParameter.create(part=self, PartParameter.create(
part=self,
template=template.parameter_template, template=template.parameter_template,
data=template.default_value, data=template.default_value,
save=True) save=True
)
except IntegrityError: except IntegrityError:
# PartParameter already exists # PartParameter already exists
pass pass

View File

@ -240,32 +240,20 @@
}); });
$("#cat-create").click(function() { $("#cat-create").click(function() {
launchModalForm(
"{% url 'category-create' %}", var fields = categoryFields();
{
follow: true,
data: {
{% if category %} {% if category %}
category: {{ category.id }} fields.parent.value = {{ category.pk }};
{% endif %} {% endif %}
},
secondary: [ constructForm('{% url "api-part-category-list" %}', {
{ fields: fields,
field: 'default_location', method: 'POST',
label: '{% trans "New Location" %}', title: '{% trans "Create Part Category" %}',
title: '{% trans "Create new location" %}', follow: true,
url: "{% url 'stock-location-create' %}", });
}, });
{
field: 'parent',
label: '{% trans "New Category" %}',
title: '{% trans "Create new category" %}',
url: "{% url 'category-create' %}",
},
]
}
);
})
$("#part-export").click(function() { $("#part-export").click(function() {
@ -276,55 +264,32 @@
{% if roles.part.add %} {% if roles.part.add %}
$("#part-create").click(function() { $("#part-create").click(function() {
launchModalForm(
"{% url 'part-create' %}", var fields = partFields({
{ create: true,
follow: true, });
data: {
{% if category %} {% if category %}
category: {{ category.id }} fields.category.value = {{ category.pk }};
{% endif %} {% endif %}
constructForm('{% url "api-part-list" %}', {
method: 'POST',
fields: fields,
title: '{% trans "Create Part" %}',
onSuccess: function(data) {
// Follow the new part
location.href = `/part/${data.pk}/`;
}, },
secondary: [ });
{
field: 'category',
label: '{% trans "New Category" %}',
title: '{% trans "Create new Part Category" %}',
url: "{% url 'category-create' %}",
},
{
field: 'default_location',
label: '{% trans "New Location" %}',
title: '{% trans "Create new Stock Location" %}',
url: "{% url 'stock-location-create' %}",
}
]
}
);
}); });
{% endif %} {% endif %}
{% if category %} {% if category %}
$("#cat-edit").click(function () { $("#cat-edit").click(function () {
constructForm( editCategory({{ category.pk }});
'{% url "api-part-category-detail" category.pk %}',
{
fields: {
name: {},
description: {},
parent: {
help_text: '{% trans "Select parent category" %}',
},
default_location: {},
default_keywords: {
icon: 'fa-key',
}
},
title: '{% trans "Edit Part Category" %}',
reload: true
}
);
}); });
{% if category.parent %} {% if category.parent %}

View File

@ -440,22 +440,22 @@
}); });
$("#bom-item-new").click(function () { $("#bom-item-new").click(function () {
launchModalForm(
"{% url 'bom-item-create' %}?parent={{ part.id }}", var fields = bomItemFields();
{
success: function() { fields.part.value = {{ part.pk }};
$("#bom-table").bootstrapTable('refresh'); fields.sub_part.filters = {
}, active: true,
secondary: [ };
{
field: 'sub_part', constructForm('{% url "api-bom-list" %}', {
label: '{% trans "New Part" %}', fields: fields,
title: '{% trans "Create New Part" %}', method: 'POST',
url: "{% url 'part-create' %}", title: '{% trans "Create BOM Item" %}',
}, onSuccess: function() {
] $('#bom-table').bootstrapTable('refresh');
} }
); });
}); });
{% else %} {% else %}
@ -525,10 +525,11 @@
loadPartVariantTable($('#variants-table'), {{ part.pk }}); loadPartVariantTable($('#variants-table'), {{ part.pk }});
$('#new-variant').click(function() { $('#new-variant').click(function() {
launchModalForm(
"{% url 'make-part-variant' part.id %}", duplicatePart(
{{ part.pk}},
{ {
follow: true, variant: true,
} }
); );
}); });
@ -899,7 +900,7 @@
{% for line in price_history %}'{{ line.date }}',{% endfor %} {% for line in price_history %}'{{ line.date }}',{% endfor %}
], ],
datasets: [{ datasets: [{
label: '{% blocktrans %}Single Price - {{currency}}{% endblocktrans %}', label: '{% blocktrans %}Purchase Unit Price - {{currency}}{% endblocktrans %}',
backgroundColor: 'rgba(255, 99, 132, 0.2)', backgroundColor: 'rgba(255, 99, 132, 0.2)',
borderColor: 'rgb(255, 99, 132)', borderColor: 'rgb(255, 99, 132)',
yAxisID: 'y', yAxisID: 'y',
@ -911,7 +912,7 @@
}, },
{% if 'price_diff' in price_history.0 %} {% if 'price_diff' in price_history.0 %}
{ {
label: '{% blocktrans %}Single Price Difference - {{currency}}{% endblocktrans %}', label: '{% blocktrans %}Unit Price-Cost Difference - {{currency}}{% endblocktrans %}',
backgroundColor: 'rgba(68, 157, 68, 0.2)', backgroundColor: 'rgba(68, 157, 68, 0.2)',
borderColor: 'rgb(68, 157, 68)', borderColor: 'rgb(68, 157, 68)',
yAxisID: 'y2', yAxisID: 'y2',
@ -923,7 +924,7 @@
hidden: true, hidden: true,
}, },
{ {
label: '{% blocktrans %}Part Single Price - {{currency}}{% endblocktrans %}', label: '{% blocktrans %}Supplier Unit Cost - {{currency}}{% endblocktrans %}',
backgroundColor: 'rgba(70, 127, 155, 0.2)', backgroundColor: 'rgba(70, 127, 155, 0.2)',
borderColor: 'rgb(70, 127, 155)', borderColor: 'rgb(70, 127, 155)',
yAxisID: 'y', yAxisID: 'y',

View File

@ -415,13 +415,16 @@
// Callback when the image-selection modal form is displayed // Callback when the image-selection modal form is displayed
// Populate the form with image data (requested via AJAX) // Populate the form with image data (requested via AJAX)
$("#modal-form").find("#image-select-table").bootstrapTable({ $("#modal-form").find("#image-select-table").inventreeTable({
pagination: true,
pageSize: 25,
url: "{% url 'api-part-thumbs' %}", url: "{% url 'api-part-thumbs' %}",
showHeader: false, showHeader: false,
showColumns: false,
clickToSelect: true, clickToSelect: true,
sidePagination: 'server',
singleSelect: true, singleSelect: true,
formatNoMatches: function() {
return '{% trans "No matching images found" %}';
},
columns: [ columns: [
{ {
checkbox: true, checkbox: true,
@ -429,6 +432,7 @@
{ {
field: 'image', field: 'image',
title: 'Image', title: 'Image',
searchable: true,
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
return "<img src='/media/" + value + "' class='grid-image'/>" return "<img src='/media/" + value + "' class='grid-image'/>"
} }
@ -482,12 +486,7 @@
{% if roles.part.add %} {% if roles.part.add %}
$("#part-duplicate").click(function() { $("#part-duplicate").click(function() {
launchModalForm( duplicatePart({{ part.pk }});
"{% url 'part-duplicate' part.id %}",
{
follow: true,
}
);
}); });
{% endif %} {% endif %}

View File

@ -161,7 +161,7 @@
<div class='panel-content'> <div class='panel-content'>
<h4>{% trans 'Stock Pricing' %} <h4>{% trans 'Stock Pricing' %}
<i class="fas fa-info-circle" title="Shows the purchase prices of stock for this part. <i class="fas fa-info-circle" title="Shows the purchase prices of stock for this part.
The part single price is the current purchase price for that supplier part."></i> The Supplier Unit Cost is the current purchase price for that supplier part."></i>
</h4> </h4>
{% if price_history|length > 0 %} {% if price_history|length > 0 %}
<div style="max-width: 99%; min-height: 300px"> <div style="max-width: 99%; min-height: 300px">

View File

@ -434,8 +434,8 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertTrue(data['active']) self.assertTrue(data['active'])
self.assertFalse(data['virtual']) self.assertFalse(data['virtual'])
# By default, parts are not purchaseable # By default, parts are purchaseable
self.assertFalse(data['purchaseable']) self.assertTrue(data['purchaseable'])
# Set the default 'purchaseable' status to True # Set the default 'purchaseable' status to True
InvenTreeSetting.set_setting( InvenTreeSetting.set_setting(

View File

@ -12,7 +12,7 @@ from django.core.exceptions import ValidationError
import os import os
from .models import Part, PartCategory, PartTestTemplate from .models import Part, PartCategory, PartTestTemplate
from .models import rename_part_image, match_part_names from .models import rename_part_image
from .templatetags import inventree_extras from .templatetags import inventree_extras
import part.settings import part.settings
@ -163,12 +163,6 @@ class PartTest(TestCase):
def test_copy(self): def test_copy(self):
self.r2.deep_copy(self.r1, image=True, bom=True) self.r2.deep_copy(self.r1, image=True, bom=True)
def test_match_names(self):
matches = match_part_names('M2x5 LPHS')
self.assertTrue(len(matches) > 0)
def test_sell_pricing(self): def test_sell_pricing(self):
# check that the sell pricebreaks were loaded # check that the sell pricebreaks were loaded
self.assertTrue(self.r1.has_price_breaks) self.assertTrue(self.r1.has_price_breaks)
@ -281,7 +275,7 @@ class PartSettingsTest(TestCase):
""" """
self.assertTrue(part.settings.part_component_default()) self.assertTrue(part.settings.part_component_default())
self.assertFalse(part.settings.part_purchaseable_default()) self.assertTrue(part.settings.part_purchaseable_default())
self.assertFalse(part.settings.part_salable_default()) self.assertFalse(part.settings.part_salable_default())
self.assertFalse(part.settings.part_trackable_default()) self.assertFalse(part.settings.part_trackable_default())
@ -293,7 +287,7 @@ class PartSettingsTest(TestCase):
part = self.make_part() part = self.make_part()
self.assertTrue(part.component) self.assertTrue(part.component)
self.assertFalse(part.purchaseable) self.assertTrue(part.purchaseable)
self.assertFalse(part.salable) self.assertFalse(part.salable)
self.assertFalse(part.trackable) self.assertFalse(part.trackable)

View File

@ -155,38 +155,6 @@ class PartDetailTest(PartViewTestCase):
self.assertIn('streaming_content', dir(response)) self.assertIn('streaming_content', dir(response))
class PartTests(PartViewTestCase):
""" Tests for Part forms """
def test_part_create(self):
""" Launch form to create a new part """
response = self.client.get(reverse('part-create'), {'category': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# And again, with an invalid category
response = self.client.get(reverse('part-create'), {'category': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# And again, with no category
response = self.client.get(reverse('part-create'), {'name': 'Test part'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
def test_part_duplicate(self):
""" Launch form to duplicate part """
# First try with an invalid part
response = self.client.get(reverse('part-duplicate', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse('part-duplicate', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
def test_make_variant(self):
response = self.client.get(reverse('make-part-variant', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
class PartRelatedTests(PartViewTestCase): class PartRelatedTests(PartViewTestCase):
def test_valid_create(self): def test_valid_create(self):
@ -243,19 +211,6 @@ class PartQRTest(PartViewTestCase):
class CategoryTest(PartViewTestCase): class CategoryTest(PartViewTestCase):
""" Tests for PartCategory related views """ """ Tests for PartCategory related views """
def test_create(self):
""" Test view for creating a new category """
response = self.client.get(reverse('category-create'), {'category': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
def test_create_invalid_parent(self):
""" test creation of a new category with an invalid parent """
response = self.client.get(reverse('category-create'), {'category': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
# Form should still return OK
self.assertEqual(response.status_code, 200)
def test_set_category(self): def test_set_category(self):
""" Test that the "SetCategory" view works """ """ Test that the "SetCategory" view works """
@ -272,22 +227,3 @@ class CategoryTest(PartViewTestCase):
response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
class BomItemTests(PartViewTestCase):
""" Tests for BomItem related views """
def test_create_valid_parent(self):
""" Create a BomItem for a valid part """
response = self.client.get(reverse('bom-item-create'), {'parent': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
def test_create_no_parent(self):
""" Create a BomItem without a parent """
response = self.client.get(reverse('bom-item-create'), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
def test_create_invalid_parent(self):
""" Create a BomItem with an invalid parent """
response = self.client.get(reverse('bom-item-create'), {'parent': 99999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)

View File

@ -40,8 +40,7 @@ part_detail_urls = [
url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'), 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'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'), url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'),
url(r'^duplicate/', views.PartDuplicate.as_view(), name='part-duplicate'),
url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'),
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'), url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'), url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
@ -65,9 +64,6 @@ category_parameter_urls = [
category_urls = [ category_urls = [
# Create a new category
url(r'^new/', views.CategoryCreate.as_view(), name='category-create'),
# Top level subcategory display # Top level subcategory display
url(r'^subcategory/', views.PartIndex.as_view(template_name='part/subcategory.html'), name='category-index-subcategory'), url(r'^subcategory/', views.PartIndex.as_view(template_name='part/subcategory.html'), name='category-index-subcategory'),
@ -81,23 +77,13 @@ category_urls = [
])) ]))
] ]
part_bom_urls = [
url(r'^edit/?', views.BomItemEdit.as_view(), name='bom-item-edit'),
]
# URL list for part web interface # URL list for part web interface
part_urls = [ part_urls = [
# Create a new part
url(r'^new/?', views.PartCreate.as_view(), name='part-create'),
# Upload a part # Upload a part
url(r'^import/', views.PartImport.as_view(), name='part-import'), url(r'^import/', views.PartImport.as_view(), name='part-import'),
url(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'), url(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'),
# Create a new BOM item
url(r'^bom/new/?', views.BomItemCreate.as_view(), name='bom-item-create'),
# Download a BOM upload template # Download a BOM upload template
url(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='bom-upload-template'), url(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='bom-upload-template'),
@ -125,9 +111,6 @@ part_urls = [
# Change category for multiple parts # Change category for multiple parts
url(r'^set-category/?', views.PartSetCategory.as_view(), name='part-set-category'), url(r'^set-category/?', views.PartSetCategory.as_view(), name='part-set-category'),
# Bom Items
url(r'^bom/(?P<pk>\d+)/', include(part_bom_urls)),
# Individual part using IPN as slug # Individual part using IPN as slug
url(r'^(?P<slug>[-\w]+)/', views.PartDetailFromIPN.as_view(), name='part-detail-from-ipn'), url(r'^(?P<slug>[-\w]+)/', views.PartDetailFromIPN.as_view(), name='part-detail-from-ipn'),

View File

@ -12,10 +12,9 @@ from django.db.utils import IntegrityError
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.shortcuts import HttpResponseRedirect from django.shortcuts import HttpResponseRedirect
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.urls import reverse, reverse_lazy from django.urls import reverse
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.forms.models import model_to_dict from django.forms import HiddenInput
from django.forms import HiddenInput, CheckboxInput
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
@ -35,7 +34,6 @@ from .models import PartCategory, Part, PartRelated
from .models import PartParameterTemplate from .models import PartParameterTemplate
from .models import PartCategoryParameterTemplate from .models import PartCategoryParameterTemplate
from .models import BomItem from .models import BomItem
from .models import match_part_names
from .models import PartSellPriceBreak, PartInternalPriceBreak from .models import PartSellPriceBreak, PartInternalPriceBreak
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
@ -44,7 +42,7 @@ from common.files import FileManager
from common.views import FileManagementFormView, FileManagementAjaxView from common.views import FileManagementFormView, FileManagementAjaxView
from common.forms import UploadFileForm, MatchFieldForm from common.forms import UploadFileForm, MatchFieldForm
from stock.models import StockItem, StockLocation from stock.models import StockLocation
import common.settings as inventree_settings import common.settings as inventree_settings
@ -233,370 +231,6 @@ class PartSetCategory(AjaxUpdateView):
return ctx return ctx
class MakePartVariant(AjaxCreateView):
""" View for creating a new variant based on an existing template Part
- Part <pk> is provided in the URL '/part/<pk>/make_variant/'
- Automatically copy relevent data (BOM, etc, etc)
"""
model = Part
form_class = part_forms.EditPartForm
ajax_form_title = _('Create Variant')
ajax_template_name = 'part/variant_part.html'
def get_part_template(self):
return get_object_or_404(Part, id=self.kwargs['pk'])
def get_context_data(self):
return {
'part': self.get_part_template(),
}
def get_form(self):
form = super(AjaxCreateView, self).get_form()
# Hide some variant-related fields
# form.fields['variant_of'].widget = HiddenInput()
# Force display of the 'bom_copy' widget
form.fields['bom_copy'].widget = CheckboxInput()
# Force display of the 'parameters_copy' widget
form.fields['parameters_copy'].widget = CheckboxInput()
return form
def post(self, request, *args, **kwargs):
form = self.get_form()
context = self.get_context_data()
part_template = self.get_part_template()
valid = form.is_valid()
data = {
'form_valid': valid,
}
if valid:
# Create the new part variant
part = form.save(commit=False)
part.variant_of = part_template
part.is_template = False
part.save()
data['pk'] = part.pk
data['text'] = str(part)
data['url'] = part.get_absolute_url()
bom_copy = str2bool(request.POST.get('bom_copy', False))
parameters_copy = str2bool(request.POST.get('parameters_copy', False))
# Copy relevent information from the template part
part.deep_copy(part_template, bom=bom_copy, parameters=parameters_copy)
return self.renderJsonResponse(request, form, data, context=context)
def get_initial(self):
part_template = self.get_part_template()
initials = model_to_dict(part_template)
initials['is_template'] = False
initials['variant_of'] = part_template
initials['bom_copy'] = InvenTreeSetting.get_setting('PART_COPY_BOM')
initials['parameters_copy'] = InvenTreeSetting.get_setting('PART_COPY_PARAMETERS')
return initials
class PartDuplicate(AjaxCreateView):
""" View for duplicating an existing Part object.
- Part <pk> is provided in the URL '/part/<pk>/copy/'
- Option for 'deep-copy' which will duplicate all BOM items (default = True)
"""
model = Part
form_class = part_forms.EditPartForm
ajax_form_title = _("Duplicate Part")
ajax_template_name = "part/copy_part.html"
def get_data(self):
return {
'success': _('Copied part')
}
def get_part_to_copy(self):
try:
return Part.objects.get(id=self.kwargs['pk'])
except (Part.DoesNotExist, ValueError):
return None
def get_context_data(self):
return {
'part': self.get_part_to_copy()
}
def get_form(self):
form = super(AjaxCreateView, self).get_form()
# Force display of the 'bom_copy' widget
form.fields['bom_copy'].widget = CheckboxInput()
# Force display of the 'parameters_copy' widget
form.fields['parameters_copy'].widget = CheckboxInput()
return form
def post(self, request, *args, **kwargs):
""" Capture the POST request for part duplication
- If the bom_copy object is set, copy all the BOM items too!
- If the parameters_copy object is set, copy all the parameters too!
"""
form = self.get_form()
context = self.get_context_data()
valid = form.is_valid()
name = request.POST.get('name', None)
if name:
matches = match_part_names(name)
if len(matches) > 0:
# Display the first five closest matches
context['matches'] = matches[:5]
# Enforce display of the checkbox
form.fields['confirm_creation'].widget = CheckboxInput()
# Check if the user has checked the 'confirm_creation' input
confirmed = str2bool(request.POST.get('confirm_creation', False))
if not confirmed:
msg = _('Possible matches exist - confirm creation of new part')
form.add_error('confirm_creation', msg)
form.pre_form_warning = msg
valid = False
data = {
'form_valid': valid
}
if valid:
# Create the new Part
part = form.save(commit=False)
part.creation_user = request.user
part.save()
data['pk'] = part.pk
data['text'] = str(part)
bom_copy = str2bool(request.POST.get('bom_copy', False))
parameters_copy = str2bool(request.POST.get('parameters_copy', False))
original = self.get_part_to_copy()
if original:
part.deep_copy(original, bom=bom_copy, parameters=parameters_copy)
try:
data['url'] = part.get_absolute_url()
except AttributeError:
pass
if valid:
pass
return self.renderJsonResponse(request, form, data, context=context)
def get_initial(self):
""" Get initial data based on the Part to be copied from.
"""
part = self.get_part_to_copy()
if part:
initials = model_to_dict(part)
else:
initials = super(AjaxCreateView, self).get_initial()
initials['bom_copy'] = str2bool(InvenTreeSetting.get_setting('PART_COPY_BOM', True))
initials['parameters_copy'] = str2bool(InvenTreeSetting.get_setting('PART_COPY_PARAMETERS', True))
return initials
class PartCreate(AjaxCreateView):
""" View for creating a new Part object.
Options for providing initial conditions:
- Provide a category object as initial data
"""
model = Part
form_class = part_forms.EditPartForm
ajax_form_title = _('Create New Part')
ajax_template_name = 'part/create_part.html'
def get_data(self):
return {
'success': _("Created new part"),
}
def get_category_id(self):
return self.request.GET.get('category', None)
def get_context_data(self, **kwargs):
""" Provide extra context information for the form to display:
- Add category information (if provided)
"""
context = super(PartCreate, self).get_context_data(**kwargs)
# Add category information to the page
cat_id = self.get_category_id()
if cat_id:
try:
context['category'] = PartCategory.objects.get(pk=cat_id)
except (PartCategory.DoesNotExist, ValueError):
pass
return context
def get_form(self):
""" Create Form for making new Part object.
Remove the 'default_supplier' field as there are not yet any matching SupplierPart objects
"""
form = super(AjaxCreateView, self).get_form()
# Hide the "default expiry" field if the feature is not enabled
if not inventree_settings.stock_expiry_enabled():
form.fields['default_expiry'].widget = HiddenInput()
# Hide the "initial stock amount" field if the feature is not enabled
if not InvenTreeSetting.get_setting('PART_CREATE_INITIAL'):
form.fields['initial_stock'].widget = HiddenInput()
# Hide the default_supplier field (there are no matching supplier parts yet!)
form.fields['default_supplier'].widget = HiddenInput()
# Display category templates widgets
form.fields['selected_category_templates'].widget = CheckboxInput()
form.fields['parent_category_templates'].widget = CheckboxInput()
return form
def post(self, request, *args, **kwargs):
form = self.get_form()
context = {}
valid = form.is_valid()
name = request.POST.get('name', None)
if name:
matches = match_part_names(name)
if len(matches) > 0:
# Limit to the top 5 matches (to prevent clutter)
context['matches'] = matches[:5]
# Enforce display of the checkbox
form.fields['confirm_creation'].widget = CheckboxInput()
# Check if the user has checked the 'confirm_creation' input
confirmed = str2bool(request.POST.get('confirm_creation', False))
if not confirmed:
msg = _('Possible matches exist - confirm creation of new part')
form.add_error('confirm_creation', msg)
form.pre_form_warning = msg
valid = False
data = {
'form_valid': valid
}
if valid:
# Create the new Part
part = form.save(commit=False)
# Record the user who created this part
part.creation_user = request.user
# Store category templates settings
add_category_templates = {
'main': form.cleaned_data['selected_category_templates'],
'parent': form.cleaned_data['parent_category_templates'],
}
# Save part and pass category template settings
part.save(**{'add_category_templates': add_category_templates})
# Add stock if set
init_stock = int(request.POST.get('initial_stock', 0))
if init_stock:
stock = StockItem(part=part,
quantity=init_stock,
location=part.default_location)
stock.save()
data['pk'] = part.pk
data['text'] = str(part)
try:
data['url'] = part.get_absolute_url()
except AttributeError:
pass
return self.renderJsonResponse(request, form, data, context=context)
def get_initial(self):
""" Get initial data for the new Part object:
- If a category is provided, pre-fill the Category field
"""
initials = super(PartCreate, self).get_initial()
if self.get_category_id():
try:
category = PartCategory.objects.get(pk=self.get_category_id())
initials['category'] = category
initials['keywords'] = category.default_keywords
except (PartCategory.DoesNotExist, ValueError):
pass
# Allow initial data to be passed through as arguments
for label in ['name', 'IPN', 'description', 'revision', 'keywords']:
if label in self.request.GET:
initials[label] = self.request.GET.get(label)
# Automatically create part parameters from category templates
initials['selected_category_templates'] = str2bool(InvenTreeSetting.get_setting('PART_CATEGORY_PARAMETERS', False))
initials['parent_category_templates'] = initials['selected_category_templates']
return initials
class PartImport(FileManagementFormView): class PartImport(FileManagementFormView):
''' Part: Upload file, match to fields and import parts(using multi-Step form) ''' ''' Part: Upload file, match to fields and import parts(using multi-Step form) '''
permission_required = 'part.add' permission_required = 'part.add'
@ -1905,49 +1539,6 @@ class CategoryDelete(AjaxDeleteView):
} }
class CategoryCreate(AjaxCreateView):
""" Create view to make a new PartCategory """
model = PartCategory
ajax_form_action = reverse_lazy('category-create')
ajax_form_title = _('Create new part category')
ajax_template_name = 'modal_form.html'
form_class = part_forms.EditCategoryForm
def get_context_data(self, **kwargs):
""" Add extra context data to template.
- If parent category provided, pass the category details to the template
"""
context = super(CategoryCreate, self).get_context_data(**kwargs).copy()
parent_id = self.request.GET.get('category', None)
if parent_id:
try:
context['category'] = PartCategory.objects.get(pk=parent_id)
except PartCategory.DoesNotExist:
pass
return context
def get_initial(self):
""" Get initial data for new PartCategory
- If parent provided, pre-fill the parent category
"""
initials = super(CategoryCreate, self).get_initial().copy()
parent_id = self.request.GET.get('category', None)
if parent_id:
try:
initials['parent'] = PartCategory.objects.get(pk=parent_id)
except PartCategory.DoesNotExist:
pass
return initials
class CategoryParameterTemplateCreate(AjaxCreateView): class CategoryParameterTemplateCreate(AjaxCreateView):
""" View for creating a new PartCategoryParameterTemplate """ """ View for creating a new PartCategoryParameterTemplate """
@ -2121,134 +1712,6 @@ class CategoryParameterTemplateDelete(AjaxDeleteView):
return self.object return self.object
class BomItemCreate(AjaxCreateView):
"""
Create view for making a new BomItem object
"""
model = BomItem
form_class = part_forms.EditBomItemForm
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Create BOM Item')
def get_form(self):
""" Override get_form() method to reduce Part selection options.
- Do not allow part to be added to its own BOM
- Remove any Part items that are already in the BOM
"""
form = super(AjaxCreateView, self).get_form()
part_id = form['part'].value()
# Construct a queryset for the part field
part_query = Part.objects.filter(active=True)
# Construct a queryset for the sub_part field
sub_part_query = Part.objects.filter(
component=True,
active=True
)
try:
part = Part.objects.get(id=part_id)
# Hide the 'part' field
form.fields['part'].widget = HiddenInput()
# Exclude the part from its own BOM
sub_part_query = sub_part_query.exclude(id=part.id)
# Eliminate any options that are already in the BOM!
sub_part_query = sub_part_query.exclude(id__in=[item.id for item in part.getRequiredParts()])
except (ValueError, Part.DoesNotExist):
pass
# Set the querysets for the fields
form.fields['part'].queryset = part_query
form.fields['sub_part'].queryset = sub_part_query
return form
def get_initial(self):
""" Provide initial data for the BomItem:
- If 'parent' provided, set the parent part field
"""
# Look for initial values
initials = super(BomItemCreate, self).get_initial().copy()
# Parent part for this item?
parent_id = self.request.GET.get('parent', None)
if parent_id:
try:
initials['part'] = Part.objects.get(pk=parent_id)
except Part.DoesNotExist:
pass
return initials
class BomItemEdit(AjaxUpdateView):
""" Update view for editing BomItem """
model = BomItem
form_class = part_forms.EditBomItemForm
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Edit BOM item')
def get_form(self):
""" Override get_form() method to filter part selection options
- Do not allow part to be added to its own BOM
- Remove any part items that are already in the BOM
"""
item = self.get_object()
form = super().get_form()
part_id = form['part'].value()
try:
part = Part.objects.get(pk=part_id)
# Construct a queryset
query = Part.objects.filter(component=True)
# Limit to "active" items, *unless* the currently selected item is not active
if item.sub_part.active:
query = query.filter(active=True)
# Prevent the parent part from being selected
query = query.exclude(pk=part_id)
# Eliminate any options that are already in the BOM,
# *except* for the item which is already selected
try:
sub_part_id = int(form['sub_part'].value())
except ValueError:
sub_part_id = -1
existing = [item.pk for item in part.getRequiredParts()]
if sub_part_id in existing:
existing.remove(sub_part_id)
query = query.exclude(id__in=existing)
form.fields['sub_part'].queryset = query
except (ValueError, Part.DoesNotExist):
pass
return form
class PartSalePriceBreakCreate(AjaxCreateView): class PartSalePriceBreakCreate(AjaxCreateView):
""" """
View for creating a sale price break for a part View for creating a sale price break for a part

View File

@ -79,7 +79,7 @@ content: "v{{report_revision}} - {{ date.isoformat }}";
{% block header_content %} {% block header_content %}
<!-- TODO - Make the company logo asset generic --> <!-- TODO - Make the company logo asset generic -->
<img class='logo' src="{% asset 'company_logo.png' %}" alt="hello" width="150"> <img class='logo' src="{% asset 'company_logo.png' %}" alt="logo" width="150">
<div class='header-right'> <div class='header-right'>
<h3> <h3>

View File

@ -8,7 +8,6 @@ from __future__ import unicode_literals
from django import forms from django import forms
from django.forms.utils import ErrorDict from django.forms.utils import ErrorDict
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from mptt.fields import TreeNodeChoiceField from mptt.fields import TreeNodeChoiceField
@ -241,14 +240,9 @@ class InstallStockForm(HelperForm):
help_text=_('Stock item to install') help_text=_('Stock item to install')
) )
quantity_to_install = RoundingDecimalFormField( to_install = forms.BooleanField(
max_digits=10, decimal_places=5, widget=forms.HiddenInput(),
initial=1, required=False,
label=_('Quantity'),
help_text=_('Stock quantity to assign'),
validators=[
MinValueValidator(0.001)
]
) )
notes = forms.CharField( notes = forms.CharField(
@ -261,7 +255,7 @@ class InstallStockForm(HelperForm):
fields = [ fields = [
'part', 'part',
'stock_item', 'stock_item',
'quantity_to_install', # 'quantity_to_install',
'notes', 'notes',
] ]

View File

@ -119,6 +119,11 @@
<h4>{% trans "Installed Stock Items" %}</h4> <h4>{% trans "Installed Stock Items" %}</h4>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div class='btn-group'>
<button type='button' class='btn btn-success' id='stock-item-install'>
<span class='fas fa-plus-circle'></span> {% trans "Install Stock Item" %}
</button>
</div>
<table class='table table-striped table-condensed' id='installed-table'></table> <table class='table table-striped table-condensed' id='installed-table'></table>
</div> </div>
</div> </div>
@ -128,6 +133,20 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$('#stock-item-install').click(function() {
launchModalForm(
"{% url 'stock-item-install' item.pk %}",
{
data: {
'part': {{ item.part.pk }},
'install_item': true,
},
reload: true,
}
);
});
loadInstalledInTable( loadInstalledInTable(
$('#installed-table'), $('#installed-table'),
{ {

View File

@ -127,9 +127,11 @@
<li><a href='#' id='stock-return-from-customer' title='{% trans "Return to stock" %}'><span class='fas fa-undo'></span> {% trans "Return to stock" %}</a></li> <li><a href='#' id='stock-return-from-customer' title='{% trans "Return to stock" %}'><span class='fas fa-undo'></span> {% trans "Return to stock" %}</a></li>
{% endif %} {% endif %}
{% if item.belongs_to %} {% if item.belongs_to %}
<li> <li><a href='#' id='stock-uninstall' title='{% trans "Uninstall stock item" %}'><span class='fas fa-unlink'></span> {% trans "Uninstall" %}</a></li>
<a href='#' id='stock-uninstall' title='{% trans "Uninstall stock item" %}'><span class='fas fa-unlink'></span> {% trans "Uninstall" %}</a> {% else %}
</li> {% if item.part.get_used_in %}
<li><a href='#' id='stock-install-in' title='{% trans "Install stock item" %}'><span class='fas fa-link'></span> {% trans "Install" %}</a></li>
{% endif %}
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
@ -461,13 +463,27 @@ $("#stock-serialize").click(function() {
); );
}); });
$('#stock-install-in').click(function() {
launchModalForm(
"{% url 'stock-item-install' item.pk %}",
{
data: {
'part': {{ item.part.pk }},
'install_in': true,
},
reload: true,
}
);
});
$('#stock-uninstall').click(function() { $('#stock-uninstall').click(function() {
launchModalForm( launchModalForm(
"{% url 'stock-item-uninstall' %}", "{% url 'stock-item-uninstall' %}",
{ {
data: { data: {
'items[]': [{{ item.pk}}], 'items[]': [{{ item.pk }}],
}, },
reload: true, reload: true,
} }

View File

@ -3,15 +3,31 @@
{% block pre_form_content %} {% block pre_form_content %}
{% if install_item %}
<p> <p>
{% trans "Install another StockItem into this item." %} {% trans "Install another Stock Item into this item." %}
</p> </p>
<p> <p>
{% trans "Stock items can only be installed if they meet the following criteria" %}: {% trans "Stock items can only be installed if they meet the following criteria" %}:
<ul> <ul>
<li>{% trans "The StockItem links to a Part which is in the BOM for this StockItem" %}</li> <li>{% trans "The Stock Item links to a Part which is in the BOM for this Stock Item" %}</li>
<li>{% trans "The StockItem is currently in stock" %}</li> <li>{% trans "The Stock Item is currently in stock" %}</li>
<li>{% trans "The Stock Item is serialized and does not belong to another item" %}</li>
</ul> </ul>
</p> </p>
{% elif install_in %}
<p>
{% trans "Install this Stock Item in another stock item." %}
</p>
<p>
{% trans "Stock items can only be installed if they meet the following criteria" %}:
<ul>
<li>{% trans "The part associated to this Stock Item belongs to another part's BOM" %}</li>
<li>{% trans "This Stock Item is serialized and does not belong to another item" %}</li>
</ul>
</p>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -536,36 +536,73 @@ class StockItemInstall(AjaxUpdateView):
part = None part = None
def get_params(self):
""" Retrieve GET parameters """
# Look at GET params
self.part_id = self.request.GET.get('part', None)
self.install_in = self.request.GET.get('install_in', False)
self.install_item = self.request.GET.get('install_item', False)
if self.part_id is None:
# Look at POST params
self.part_id = self.request.POST.get('part', None)
try:
self.part = Part.objects.get(pk=self.part_id)
except (ValueError, Part.DoesNotExist):
self.part = None
def get_stock_items(self): def get_stock_items(self):
""" """
Return a list of stock items suitable for displaying to the user. Return a list of stock items suitable for displaying to the user.
Requirements: Requirements:
- Items must be in stock - Items must be in stock
- Items must be in BOM of stock item
Filters: - Items must be serialized
- Items can be filtered by Part reference
""" """
# Filter items in stock
items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER) items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER)
# Filter by Part association # Filter serialized stock items
items = items.exclude(serial__isnull=True).exclude(serial__exact='')
# Look at GET params if self.part:
part_id = self.request.GET.get('part', None) # Filter for parts to install this item in
if self.install_in:
# Get parts using this part
allowed_parts = self.part.get_used_in()
# Filter
items = items.filter(part__in=allowed_parts)
if part_id is None: # Filter for parts to install in this item
# Look at POST params if self.install_item:
part_id = self.request.POST.get('part', None) # Get parts used in this part's BOM
bom_items = self.part.get_bom_items()
try: allowed_parts = [item.sub_part for item in bom_items]
self.part = Part.objects.get(pk=part_id) # Filter
items = items.filter(part=self.part) items = items.filter(part__in=allowed_parts)
except (ValueError, Part.DoesNotExist):
self.part = None
return items return items
def get_context_data(self, **kwargs):
""" Retrieve parameters and update context """
ctx = super().get_context_data(**kwargs)
# Get request parameters
self.get_params()
ctx.update({
'part': self.part,
'install_in': self.install_in,
'install_item': self.install_item,
})
return ctx
def get_initial(self): def get_initial(self):
initials = super().get_initial() initials = super().get_initial()
@ -576,11 +613,16 @@ class StockItemInstall(AjaxUpdateView):
if items.count() == 1: if items.count() == 1:
item = items.first() item = items.first()
initials['stock_item'] = item.pk initials['stock_item'] = item.pk
initials['quantity_to_install'] = item.quantity
if self.part: if self.part:
initials['part'] = self.part initials['part'] = self.part
try:
# Is this stock item being installed in the other stock item?
initials['to_install'] = self.install_in or not self.install_item
except AttributeError:
pass
return initials return initials
def get_form(self): def get_form(self):
@ -593,6 +635,8 @@ class StockItemInstall(AjaxUpdateView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.get_params()
form = self.get_form() form = self.get_form()
valid = form.is_valid() valid = form.is_valid()
@ -602,12 +646,18 @@ class StockItemInstall(AjaxUpdateView):
data = form.cleaned_data data = form.cleaned_data
other_stock_item = data['stock_item'] other_stock_item = data['stock_item']
quantity = data['quantity_to_install'] # Quantity will always be 1 for serialized item
quantity = 1
notes = data['notes'] notes = data['notes']
# Install the other stock item into this one # Get stock item
this_stock_item = self.get_object() this_stock_item = self.get_object()
if data['to_install']:
# Install this stock item into the other stock item
other_stock_item.installStockItem(this_stock_item, quantity, request.user, notes)
else:
# Install the other stock item into this one
this_stock_item.installStockItem(other_stock_item, quantity, request.user, notes) this_stock_item.installStockItem(other_stock_item, quantity, request.user, notes)
data = { data = {

View File

@ -8,6 +8,26 @@
*/ */
function bomItemFields() {
return {
part: {
hidden: true,
},
sub_part: {
},
quantity: {},
reference: {},
overage: {},
note: {},
allow_variants: {},
inherited: {},
optional: {},
};
}
function reloadBomTable(table, options) { function reloadBomTable(table, options) {
table.bootstrapTable('refresh'); table.bootstrapTable('refresh');
@ -262,13 +282,13 @@ function loadBomTable(table, options) {
cols.push( cols.push(
{ {
field: 'price_range', field: 'price_range',
title: '{% trans "Buy Price" %}', title: '{% trans "Supplier Cost" %}',
sortable: true, sortable: true,
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
if (value) { if (value) {
return value; return value;
} else { } else {
return "<span class='warning-msg'>{% trans 'No pricing available' %}</span>"; return "<span class='warning-msg'>{% trans 'No supplier pricing available' %}</span>";
} }
} }
}); });
@ -528,14 +548,15 @@ function loadBomTable(table, options) {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
var url = `/part/bom/${pk}/edit/`; var url = `/part/bom/${pk}/edit/`;
launchModalForm( var fields = bomItemFields();
url,
{ constructForm(`/api/bom/${pk}/`, {
success: function() { fields: fields,
title: '{% trans "Edit BOM Item" %}',
onSuccess: function() {
reloadBomTable(table); reloadBomTable(table);
} }
} });
);
}); });
table.on('click', '.bom-validate-button', function() { table.on('click', '.bom-validate-button', function() {

View File

@ -927,7 +927,7 @@ function loadBuildTable(table, options) {
}, },
{ {
field: 'responsible', field: 'responsible',
title: '{% trans "Resposible" %}', title: '{% trans "Responsible" %}',
sortable: true, sortable: true,
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
if (value) if (value)

View File

@ -265,6 +265,8 @@ function setupFilterList(tableKey, table, target) {
// One blank slate, please // One blank slate, please
element.empty(); element.empty();
element.append(`<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-default filter-tag'><span class='fas fa-redo-alt'></span></button>`);
element.append(`<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-default filter-tag'><span class='fas fa-filter'></span></button>`); element.append(`<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-default filter-tag'><span class='fas fa-filter'></span></button>`);
if (Object.keys(filters).length > 0) { if (Object.keys(filters).length > 0) {
@ -279,6 +281,11 @@ function setupFilterList(tableKey, table, target) {
element.append(`<div title='${description}' class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`); element.append(`<div title='${description}' class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`);
} }
// Callback for reloading the table
element.find(`#reload-${tableKey}`).click(function() {
$(table).bootstrapTable('refresh');
});
// Add a callback for adding a new filter // Add a callback for adding a new filter
element.find(`#${add}`).click(function clicked() { element.find(`#${add}`).click(function clicked() {

View File

@ -240,6 +240,7 @@ function constructDeleteForm(fields, options) {
* - hidden: Set to true to hide the field * - hidden: Set to true to hide the field
* - icon: font-awesome icon to display before the field * - icon: font-awesome icon to display before the field
* - prefix: Custom HTML prefix to display before the field * - prefix: Custom HTML prefix to display before the field
* - data: map of data to fill out field values with
* - focus: Name of field to focus on when modal is displayed * - focus: Name of field to focus on when modal is displayed
* - preventClose: Set to true to prevent form from closing on success * - preventClose: Set to true to prevent form from closing on success
* - onSuccess: callback function when form action is successful * - onSuccess: callback function when form action is successful
@ -263,6 +264,11 @@ function constructForm(url, options) {
// Default HTTP method // Default HTTP method
options.method = options.method || 'PATCH'; options.method = options.method || 'PATCH';
// Construct an "empty" data object if not provided
if (!options.data) {
options.data = {};
}
// Request OPTIONS endpoint from the API // Request OPTIONS endpoint from the API
getApiEndpointOptions(url, function(OPTIONS) { getApiEndpointOptions(url, function(OPTIONS) {
@ -346,11 +352,20 @@ function constructFormBody(fields, options) {
// otherwise *all* fields will be displayed // otherwise *all* fields will be displayed
var displayed_fields = options.fields || fields; var displayed_fields = options.fields || fields;
// Handle initial data overrides
if (options.data) {
for (const field in options.data) {
if (field in fields) {
fields[field].value = options.data[field];
}
}
}
// Provide each field object with its own name // Provide each field object with its own name
for(field in fields) { for(field in fields) {
fields[field].name = field; fields[field].name = field;
// If any "instance_filters" are defined for the endpoint, copy them across (overwrite) // If any "instance_filters" are defined for the endpoint, copy them across (overwrite)
if (fields[field].instance_filters) { if (fields[field].instance_filters) {
fields[field].filters = Object.assign(fields[field].filters || {}, fields[field].instance_filters); fields[field].filters = Object.assign(fields[field].filters || {}, fields[field].instance_filters);
@ -366,6 +381,10 @@ function constructFormBody(fields, options) {
// TODO: Refactor the following code with Object.assign (see above) // TODO: Refactor the following code with Object.assign (see above)
// "before" and "after" renders
fields[field].before = field_options.before;
fields[field].after = field_options.after;
// Secondary modal options // Secondary modal options
fields[field].secondary = field_options.secondary; fields[field].secondary = field_options.secondary;
@ -560,10 +579,15 @@ function submitFormData(fields, options) {
var has_files = false; var has_files = false;
// Extract values for each field // Extract values for each field
options.field_names.forEach(function(name) { for (var idx = 0; idx < options.field_names.length; idx++) {
var name = options.field_names[idx];
var field = fields[name] || null; var field = fields[name] || null;
// Ignore visual fields
if (field && field.type == 'candy') continue;
if (field) { if (field) {
var value = getFormFieldValue(name, field, options); var value = getFormFieldValue(name, field, options);
@ -593,7 +617,7 @@ function submitFormData(fields, options) {
} else { } else {
console.log(`WARNING: Could not find field matching '${name}'`); console.log(`WARNING: Could not find field matching '${name}'`);
} }
}); }
var upload_func = inventreePut; var upload_func = inventreePut;
@ -1279,6 +1303,11 @@ function renderModelData(name, model, data, parameters, options) {
*/ */
function constructField(name, parameters, options) { function constructField(name, parameters, options) {
// Shortcut for simple visual fields
if (parameters.type == 'candy') {
return constructCandyInput(name, parameters, options);
}
var field_name = `id_${name}`; var field_name = `id_${name}`;
// Hidden inputs are rendered without label / help text / etc // Hidden inputs are rendered without label / help text / etc
@ -1292,7 +1321,14 @@ function constructField(name, parameters, options) {
form_classes += ' has-error'; form_classes += ' has-error';
} }
var html = `<div id='div_${field_name}' class='${form_classes}'>`; var html = '';
// Optional content to render before the field
if (parameters.before) {
html += parameters.before;
}
html += `<div id='div_${field_name}' class='${form_classes}'>`;
// Add a label // Add a label
html += constructLabel(name, parameters); html += constructLabel(name, parameters);
@ -1352,6 +1388,10 @@ function constructField(name, parameters, options) {
html += `</div>`; // controls html += `</div>`; // controls
html += `</div>`; // form-group html += `</div>`; // form-group
if (parameters.after) {
html += parameters.after;
}
return html; return html;
} }
@ -1430,6 +1470,9 @@ function constructInput(name, parameters, options) {
case 'date': case 'date':
func = constructDateInput; func = constructDateInput;
break; break;
case 'candy':
func = constructCandyInput;
break;
default: default:
// Unsupported field type! // Unsupported field type!
break; break;
@ -1658,6 +1701,17 @@ function constructDateInput(name, parameters, options) {
} }
/*
* Construct a "candy" field input
* No actual field data!
*/
function constructCandyInput(name, parameters, options) {
return parameters.html;
}
/* /*
* Construct a 'help text' div based on the field parameters * Construct a 'help text' div based on the field parameters
* *

View File

@ -13,91 +13,213 @@ function yesNoLabel(value) {
} }
} }
// Construct fieldset for part forms
function partFields(options={}) {
var fields = {
category: {},
name: {},
IPN: {},
revision: {},
description: {},
variant_of: {},
keywords: {
icon: 'fa-key',
},
units: {},
link: {
icon: 'fa-link',
},
default_location: {},
default_supplier: {},
default_expiry: {
icon: 'fa-calendar-alt',
},
minimum_stock: {
icon: 'fa-boxes',
},
attributes: {
type: 'candy',
html: `<hr><h4><i>{% trans "Part Attributes" %}</i></h4><hr>`
},
component: {
value: global_settings.PART_COMPONENT,
},
assembly: {
value: global_settings.PART_ASSEMBLY,
},
is_template: {
value: global_settings.PART_TEMPLATE,
},
trackable: {
value: global_settings.PART_TRACKABLE,
},
purchaseable: {
value: global_settings.PART_PURCHASEABLE,
},
salable: {
value: global_settings.PART_SALABLE,
},
virtual: {
value: global_settings.PART_VIRTUAL,
},
};
// If editing a part, we can set the "active" status
if (options.edit) {
fields.active = {};
}
// Pop expiry field
if (!global_settings.STOCK_ENABLE_EXPIRY) {
delete fields["default_expiry"];
}
// Additional fields when "creating" a new part
if (options.create) {
// No supplier parts available yet
delete fields["default_supplier"];
fields.create = {
type: 'candy',
html: `<hr><h4><i>{% trans "Part Creation Options" %}</i></h4><hr>`,
};
if (global_settings.PART_CREATE_INITIAL) {
fields.initial_stock = {
type: 'decimal',
label: '{% trans "Initial Stock Quantity" %}',
help_text: '{% trans "Initialize part stock with specified quantity" %}',
};
}
fields.copy_category_parameters = {
type: 'boolean',
label: '{% trans "Copy Category Parameters" %}',
help_text: '{% trans "Copy parameter templates from selected part category" %}',
value: global_settings.PART_CATEGORY_PARAMETERS,
};
}
// Additional fields when "duplicating" a part
if (options.duplicate) {
fields.duplicate = {
type: 'candy',
html: `<hr><h4><i>{% trans "Part Duplication Options" %}</i></h4><hr>`,
};
fields.copy_from = {
type: 'integer',
hidden: true,
value: options.duplicate,
},
fields.copy_image = {
type: 'boolean',
label: '{% trans "Copy Image" %}',
help_text: '{% trans "Copy image from original part" %}',
value: true,
},
fields.copy_bom = {
type: 'boolean',
label: '{% trans "Copy BOM" %}',
help_text: '{% trans "Copy bill of materials from original part" %}',
value: global_settings.PART_COPY_BOM,
};
fields.copy_parameters = {
type: 'boolean',
label: '{% trans "Copy Parameters" %}',
help_text: '{% trans "Copy parameter data from original part" %}',
value: global_settings.PART_COPY_PARAMETERS,
};
}
return fields;
}
function categoryFields() {
return {
parent: {
help_text: '{% trans "Parent part category" %}',
},
name: {},
description: {},
default_location: {},
default_keywords: {
icon: 'fa-key',
}
};
}
// Edit a PartCategory via the API
function editCategory(pk, options={}) {
var url = `/api/part/category/${pk}/`;
var fields = categoryFields();
constructForm(url, {
fields: fields,
title: '{% trans "Edit Part Category" %}',
reload: true,
});
}
function editPart(pk, options={}) { function editPart(pk, options={}) {
var url = `/api/part/${pk}/`; var url = `/api/part/${pk}/`;
var fields = { var fields = partFields({
category: { edit: true
/* });
secondary: {
label: '{% trans "New Category" %}',
title: '{% trans "Create New Part Category" %}',
api_url: '{% url "api-part-category-list" %}',
method: 'POST',
fields: {
name: {},
description: {},
parent: {
secondary: {
title: '{% trans "New Parent" %}',
api_url: '{% url "api-part-category-list" %}',
method: 'POST',
fields: {
name: {},
description: {},
parent: {},
}
}
},
}
},
*/
},
name: {
placeholder: 'part name',
},
IPN: {},
description: {},
revision: {},
keywords: {
icon: 'fa-key',
},
variant_of: {},
link: {
icon: 'fa-link',
},
default_location: {
/*
secondary: {
label: '{% trans "New Location" %}',
title: '{% trans "Create new stock location" %}',
},
*/
},
default_supplier: {
filters: {
part: pk,
part_detail: true,
manufacturer_detail: true,
supplier_detail: true,
},
/*
secondary: {
label: '{% trans "New Supplier Part" %}',
title: '{% trans "Create new supplier part" %}',
}
*/
},
units: {},
minimum_stock: {},
virtual: {},
is_template: {},
assembly: {},
component: {},
trackable: {},
purchaseable: {},
salable: {},
active: {},
};
constructForm(url, { constructForm(url, {
fields: fields, fields: fields,
title: '{% trans "Edit Part" %}', title: '{% trans "Edit Part" %}',
reload: true, reload: true,
}); });
}
// Launch form to duplicate a part
function duplicatePart(pk, options={}) {
// First we need all the part information
inventreeGet(`/api/part/${pk}/`, {}, {
success: function(data) {
var fields = partFields({
duplicate: pk,
});
// If we are making a "variant" part
if (options.variant) {
// Override the "variant_of" field
data.variant_of = pk;
}
constructForm('{% url "api-part-list" %}', {
method: 'POST',
fields: fields,
title: '{% trans "Duplicate Part" %}',
data: data,
onSuccess: function(data) {
// Follow the new part
location.href = `/part/${data.pk}/`;
}
});
}
});
} }

View File

@ -187,7 +187,7 @@ $.fn.inventreeTable = function(options) {
if (!options.disablePagination) { if (!options.disablePagination) {
options.pagination = true; options.pagination = true;
options.paginationVAlign = options.paginationVAlign || 'both'; options.paginationVAlign = options.paginationVAlign || 'both';
options.pageSize = inventreeLoad(varName, 25); options.pageSize = options.pageSize || inventreeLoad(varName, 25);
options.pageList = [25, 50, 100, 250, 'all']; options.pageList = [25, 50, 100, 250, 'all'];
options.totalField = 'count'; options.totalField = 'count';
options.dataField = 'results'; options.dataField = 'results';

View File

@ -21,7 +21,8 @@ coverage==5.3 # Unit test coverage
coveralls==2.1.2 # Coveralls linking (for Travis) coveralls==2.1.2 # Coveralls linking (for Travis)
rapidfuzz==0.7.6 # Fuzzy string matching rapidfuzz==0.7.6 # Fuzzy string matching
django-stdimage==5.1.1 # Advanced ImageField management django-stdimage==5.1.1 # Advanced ImageField management
django-weasyprint==1.0.1 # HTML PDF export weasyprint==52.5 # PDF generation library (Note: in the future need to update to 53)
django-weasyprint==1.0.1 # django weasyprint integration
django-debug-toolbar==2.2 # Debug / profiling toolbar django-debug-toolbar==2.2 # Debug / profiling toolbar
django-admin-shell==0.1.2 # Python shell for the admin interface django-admin-shell==0.1.2 # Python shell for the admin interface
py-moneyed==0.8.0 # Specific version requirement for py-moneyed py-moneyed==0.8.0 # Specific version requirement for py-moneyed