Merge remote-tracking branch 'inventree/master' into 0.4.x

This commit is contained in:
Oliver 2021-08-05 08:43:04 +10:00
commit 96b5f70c21
18 changed files with 402 additions and 892 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

@ -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
@ -628,16 +630,75 @@ 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
part.save()
# 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()
# 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):

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,39 +385,29 @@ 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 category in parent_categories:
for template in category.get_parameter_templates(): for template in category.get_parameter_templates():
parameter = PartParameter.create(part=self, # Check that template wasn't already added
template=template.parameter_template, if template.parameter_template not in template_list:
data=template.default_value,
save=True)
if parameter:
template_list.append(template.parameter_template) template_list.append(template.parameter_template)
# Create part parameters for parent category try:
category_templates = add_category_templates['parent'] PartParameter.create(
if category_templates: part=self,
# Get parent categories template=template.parameter_template,
parent_categories = category.get_ancestors() data=template.default_value,
save=True
for category in parent_categories: )
for template in category.get_parameter_templates(): except IntegrityError:
# Check that template wasn't already added # PartParameter already exists
if template.parameter_template not in template_list: pass
try:
PartParameter.create(part=self,
template=template.parameter_template,
data=template.default_value,
save=True)
except IntegrityError:
# PartParameter already exists
pass
def __str__(self): def __str__(self):
return f"{self.full_name} - {self.description}" return f"{self.full_name} - {self.description}"

View File

@ -264,25 +264,25 @@
{% 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 %}
},
secondary: [ constructForm('{% url "api-part-list" %}', {
{ method: 'POST',
field: 'default_location', fields: fields,
label: '{% trans "New Location" %}', title: '{% trans "Create Part" %}',
title: '{% trans "Create new Stock Location" %}', onSuccess: function(data) {
url: "{% url 'stock-location-create' %}", // Follow the new part
} location.href = `/part/${data.pk}/`;
] },
} });
);
}); });
{% endif %} {% endif %}

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,
} }
); );
}); });

View File

@ -486,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

@ -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):
@ -259,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'),
@ -78,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'),
@ -122,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

@ -14,8 +14,7 @@ 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 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'
@ -2078,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

@ -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');
@ -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,
reloadBomTable(table); title: '{% trans "Edit BOM Item" %}',
} onSuccess: function() {
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,10 +352,19 @@ 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) {
@ -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,6 +13,134 @@ 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() { function categoryFields() {
return { return {
@ -49,86 +177,49 @@ 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}/`;
}
});
}
});
} }