diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index 58d33697b7..baf08e112b 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -85,8 +85,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): """ 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 and data is not empty: @@ -193,7 +195,15 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): try: instance.full_clean() 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 diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 5d75a4dd74..839780d5b4 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -637,7 +637,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'PART_PURCHASEABLE': { 'name': _('Purchaseable'), 'description': _('Parts are purchaseable by default'), - 'default': False, + 'default': True, 'validator': bool, }, @@ -662,6 +662,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, }, + # TODO: Remove this setting in future, new API forms make this not useful 'PART_SHOW_QUANTITY_IN_FORMS': { 'name': _('Show Quantity in Forms'), 'description': _('Display available part quantity in some forms'), diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index a01b05034f..789ba9b9b7 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -23,6 +23,7 @@ from djmoney.money import Money from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.exceptions import MissingRate +from decimal import Decimal from .models import Part, PartCategory, BomItem from .models import PartParameter, PartParameterTemplate @@ -30,6 +31,7 @@ from .models import PartAttachment, PartTestTemplate from .models import PartSellPriceBreak, PartInternalPriceBreak from .models import PartCategoryParameterTemplate +from stock.models import StockItem from common.models import InvenTreeSetting from build.models import Build @@ -628,16 +630,75 @@ class PartList(generics.ListCreateAPIView): else: return Response(data) - def perform_create(self, serializer): + def create(self, request, *args, **kwargs): """ We wish to save the user who created this part! Note: Implementation copied from DRF class CreateModelMixin """ + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + part = serializer.save() 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): diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 1fc2848440..f5d7d39266 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -177,82 +177,6 @@ class SetPartCategoryForm(forms.Form): 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): """ Form for editing a PartParameterTemplate object """ diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 2dd5d3ad7f..28fd3ce793 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -34,7 +34,6 @@ from stdimage.models import StdImageField from decimal import Decimal, InvalidOperation from datetime import datetime -from rapidfuzz import fuzz import hashlib from InvenTree import helpers @@ -235,57 +234,6 @@ def rename_part_image(instance, filename): 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): """ Defines a custom object manager for the Part model. @@ -409,7 +357,7 @@ class Part(MPTTModel): """ # 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: previous = Part.objects.get(pk=self.pk) @@ -437,39 +385,29 @@ class Part(MPTTModel): # Get part category category = self.category - if category and add_category_templates: - # Store templates added to part + if category is not None: + template_list = [] - # Create part parameters for selected category - category_templates = add_category_templates['main'] - if category_templates: + parent_categories = category.get_ancestors(include_self=True) + + for category in parent_categories: for template in category.get_parameter_templates(): - parameter = PartParameter.create(part=self, - template=template.parameter_template, - data=template.default_value, - save=True) - if parameter: + # Check that template wasn't already added + if template.parameter_template not in template_list: + 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 template in category.get_parameter_templates(): - # Check that template wasn't already added - if template.parameter_template not in template_list: - try: - PartParameter.create(part=self, - template=template.parameter_template, - data=template.default_value, - save=True) - except IntegrityError: - # PartParameter already exists - 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): return f"{self.full_name} - {self.description}" diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 1c41092574..b149fd28ed 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -264,25 +264,25 @@ {% if roles.part.add %} $("#part-create").click(function() { - launchModalForm( - "{% url 'part-create' %}", - { - follow: true, - data: { - {% if category %} - category: {{ category.id }} - {% endif %} - }, - secondary: [ - { - field: 'default_location', - label: '{% trans "New Location" %}', - title: '{% trans "Create new Stock Location" %}', - url: "{% url 'stock-location-create' %}", - } - ] - } - ); + + var fields = partFields({ + create: true, + }); + + {% if category %} + fields.category.value = {{ category.pk }}; + {% 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}/`; + }, + }); + }); {% endif %} diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 267b880d49..165ea37e66 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -525,10 +525,11 @@ loadPartVariantTable($('#variants-table'), {{ part.pk }}); $('#new-variant').click(function() { - launchModalForm( - "{% url 'make-part-variant' part.id %}", + + duplicatePart( + {{ part.pk}}, { - follow: true, + variant: true, } ); }); diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index ec637412a8..0c29f1c26b 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -486,12 +486,7 @@ {% if roles.part.add %} $("#part-duplicate").click(function() { - launchModalForm( - "{% url 'part-duplicate' part.id %}", - { - follow: true, - } - ); + duplicatePart({{ part.pk }}); }); {% endif %} diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 7700c5c61f..bbd73b73e0 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -434,8 +434,8 @@ class PartAPITest(InvenTreeAPITestCase): self.assertTrue(data['active']) self.assertFalse(data['virtual']) - # By default, parts are not purchaseable - self.assertFalse(data['purchaseable']) + # By default, parts are purchaseable + self.assertTrue(data['purchaseable']) # Set the default 'purchaseable' status to True InvenTreeSetting.set_setting( diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index e30c80549f..1bd9fdf87d 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -12,7 +12,7 @@ from django.core.exceptions import ValidationError import os 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 import part.settings @@ -163,12 +163,6 @@ class PartTest(TestCase): def test_copy(self): 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): # check that the sell pricebreaks were loaded self.assertTrue(self.r1.has_price_breaks) @@ -281,7 +275,7 @@ class PartSettingsTest(TestCase): """ 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_trackable_default()) @@ -293,7 +287,7 @@ class PartSettingsTest(TestCase): part = self.make_part() self.assertTrue(part.component) - self.assertFalse(part.purchaseable) + self.assertTrue(part.purchaseable) self.assertFalse(part.salable) self.assertFalse(part.trackable) diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py index 206d4dd56a..5f2a9b1583 100644 --- a/InvenTree/part/test_views.py +++ b/InvenTree/part/test_views.py @@ -155,38 +155,6 @@ class PartDetailTest(PartViewTestCase): 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): def test_valid_create(self): diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 52e9b929c1..13fc6f7c16 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -40,8 +40,7 @@ part_detail_urls = [ url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'), url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'), 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'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'), @@ -81,9 +80,6 @@ category_urls = [ # URL list for part web interface part_urls = [ - # Create a new part - url(r'^new/?', views.PartCreate.as_view(), name='part-create'), - # Upload a part url(r'^import/', views.PartImport.as_view(), name='part-import'), url(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index b35e752351..0e06678694 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -14,8 +14,7 @@ from django.shortcuts import HttpResponseRedirect from django.utils.translation import gettext_lazy as _ from django.urls import reverse from django.views.generic import DetailView, ListView -from django.forms.models import model_to_dict -from django.forms import HiddenInput, CheckboxInput +from django.forms import HiddenInput from django.conf import settings from django.contrib import messages @@ -35,7 +34,6 @@ from .models import PartCategory, Part, PartRelated from .models import PartParameterTemplate from .models import PartCategoryParameterTemplate from .models import BomItem -from .models import match_part_names from .models import PartSellPriceBreak, PartInternalPriceBreak from common.models import InvenTreeSetting @@ -44,7 +42,7 @@ from common.files import FileManager from common.views import FileManagementFormView, FileManagementAjaxView from common.forms import UploadFileForm, MatchFieldForm -from stock.models import StockItem, StockLocation +from stock.models import StockLocation import common.settings as inventree_settings @@ -233,370 +231,6 @@ class PartSetCategory(AjaxUpdateView): return ctx -class MakePartVariant(AjaxCreateView): - """ View for creating a new variant based on an existing template Part - - - Part is provided in the URL '/part//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 is provided in the URL '/part//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): ''' Part: Upload file, match to fields and import parts(using multi-Step form) ''' permission_required = 'part.add' diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 4801ec77eb..3b55802f38 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -240,6 +240,7 @@ function constructDeleteForm(fields, options) { * - hidden: Set to true to hide the field * - icon: font-awesome icon 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 * - preventClose: Set to true to prevent form from closing on success * - onSuccess: callback function when form action is successful @@ -263,6 +264,11 @@ function constructForm(url, options) { // Default HTTP method options.method = options.method || 'PATCH'; + // Construct an "empty" data object if not provided + if (!options.data) { + options.data = {}; + } + // Request OPTIONS endpoint from the API getApiEndpointOptions(url, function(OPTIONS) { @@ -346,10 +352,19 @@ function constructFormBody(fields, options) { // otherwise *all* fields will be displayed 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 for(field in fields) { fields[field].name = field; - // If any "instance_filters" are defined for the endpoint, copy them across (overwrite) if (fields[field].instance_filters) { @@ -366,6 +381,10 @@ function constructFormBody(fields, options) { // 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 fields[field].secondary = field_options.secondary; @@ -560,10 +579,15 @@ function submitFormData(fields, options) { var has_files = false; // 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; + // Ignore visual fields + if (field && field.type == 'candy') continue; + if (field) { var value = getFormFieldValue(name, field, options); @@ -593,7 +617,7 @@ function submitFormData(fields, options) { } else { console.log(`WARNING: Could not find field matching '${name}'`); } - }); + } var upload_func = inventreePut; @@ -1279,6 +1303,11 @@ function renderModelData(name, model, data, 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}`; // Hidden inputs are rendered without label / help text / etc @@ -1292,7 +1321,14 @@ function constructField(name, parameters, options) { form_classes += ' has-error'; } - var html = `
`; + var html = ''; + + // Optional content to render before the field + if (parameters.before) { + html += parameters.before; + } + + html += `
`; // Add a label html += constructLabel(name, parameters); @@ -1352,6 +1388,10 @@ function constructField(name, parameters, options) { html += `
`; // controls html += `
`; // form-group + if (parameters.after) { + html += parameters.after; + } + return html; } @@ -1430,6 +1470,9 @@ function constructInput(name, parameters, options) { case 'date': func = constructDateInput; break; + case 'candy': + func = constructCandyInput; + break; default: // Unsupported field type! 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 * diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index aaee9e47a0..3def7abdad 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -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: `

{% trans "Part Attributes" %}


` + }, + 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: `

{% trans "Part Creation Options" %}


`, + }; + + 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: `

{% trans "Part Duplication Options" %}


`, + }; + + 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 { @@ -49,86 +177,49 @@ function editPart(pk, options={}) { var url = `/api/part/${pk}/`; - var fields = { - category: { - /* - 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: {}, - }; + var fields = partFields({ + edit: true + }); constructForm(url, { fields: fields, title: '{% trans "Edit Part" %}', 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}/`; + } + }); + } + }); }