From ac3dcac64167700ea701c898117aaf71171d57a4 Mon Sep 17 00:00:00 2001 From: eeintech Date: Mon, 2 Aug 2021 15:05:24 -0400 Subject: [PATCH 01/28] Re-enabled installing stock items into others --- .../build/templates/build/build_base.html | 4 +- InvenTree/stock/forms.py | 23 +++-- InvenTree/stock/templates/stock/item.html | 19 ++++ .../stock/templates/stock/item_base.html | 24 ++++- .../stock/templates/stock/item_install.html | 22 ++++- InvenTree/stock/views.py | 92 +++++++++++++++---- 6 files changed, 146 insertions(+), 38 deletions(-) diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index 5770777d28..e3119e6fdb 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -111,8 +111,8 @@ src="{% static 'img/blank_image.png' %}"
  • {% trans "Cancel Build" %}
  • {% endif %} {% if build.status == BuildStatus.CANCELLED and roles.build.delete %} -
  • {% trans "Delete Build"% } - {% endif %} +
  • {% trans "Delete Build" %} + {% endif %} {% endif %} diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index b23b71a2d6..b088a6cdef 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -241,16 +241,21 @@ class InstallStockForm(HelperForm): help_text=_('Stock item to install') ) - quantity_to_install = RoundingDecimalFormField( - max_digits=10, decimal_places=5, - initial=1, - label=_('Quantity'), - help_text=_('Stock quantity to assign'), - validators=[ - MinValueValidator(0.001) - ] + to_install = forms.BooleanField( + widget=forms.HiddenInput(), + required=False ) + # quantity_to_install = RoundingDecimalFormField( + # max_digits=10, decimal_places=5, + # initial=1, + # label=_('Quantity'), + # help_text=_('Stock quantity to assign'), + # validators=[ + # MinValueValidator(0.001) + # ] + # ) + notes = forms.CharField( required=False, help_text=_('Notes') @@ -261,7 +266,7 @@ class InstallStockForm(HelperForm): fields = [ 'part', 'stock_item', - 'quantity_to_install', + # 'quantity_to_install', 'notes', ] diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index 8a00c1c5e6..d380ea3369 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -119,6 +119,11 @@

    {% trans "Installed Stock Items" %}

    +
    + +
    @@ -128,6 +133,20 @@ {% block js_ready %} {{ 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( $('#installed-table'), { diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index b16d9b0b1a..93698e3d05 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -127,9 +127,11 @@
  • {% trans "Return to stock" %}
  • {% endif %} {% if item.belongs_to %} -
  • - {% trans "Uninstall" %} -
  • +
  • {% trans "Uninstall" %}
  • + {% else %} + {% if item.part.get_used_in %} +
  • {% trans "Install" %}
  • + {% endif %} {% endif %} @@ -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() { launchModalForm( "{% url 'stock-item-uninstall' %}", { data: { - 'items[]': [{{ item.pk}}], + 'items[]': [{{ item.pk }}], }, reload: true, } diff --git a/InvenTree/stock/templates/stock/item_install.html b/InvenTree/stock/templates/stock/item_install.html index 04798972d2..8a94f304d3 100644 --- a/InvenTree/stock/templates/stock/item_install.html +++ b/InvenTree/stock/templates/stock/item_install.html @@ -3,15 +3,31 @@ {% block pre_form_content %} +{% if install_item %}

    - {% trans "Install another StockItem into this item." %} + {% trans "Install another Stock Item into this item." %}

    {% trans "Stock items can only be installed if they meet the following criteria" %}:

    +{% elif install_in %} +

    + {% trans "Install this Stock Item in another stock item." %} +

    +

    + {% trans "Stock items can only be installed if they meet the following criteria" %}: + +

    +

    +{% endif %} + {% endblock %} \ No newline at end of file diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 80968e5aa9..25f8cefac1 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -518,36 +518,73 @@ class StockItemInstall(AjaxUpdateView): 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): """ Return a list of stock items suitable for displaying to the user. Requirements: - Items must be in stock - - Filters: - - Items can be filtered by Part reference + - Items must be in BOM of stock item + - Items must be serialized """ - + + # Filter items in stock 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 - part_id = self.request.GET.get('part', None) + if self.part: + # 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: - # Look at POST params - part_id = self.request.POST.get('part', None) - - try: - self.part = Part.objects.get(pk=part_id) - items = items.filter(part=self.part) - except (ValueError, Part.DoesNotExist): - self.part = None + # Filter for parts to install in this item + if self.install_item: + # Get parts used in this part's BOM + bom_items = self.part.get_bom_items() + allowed_parts = [item.sub_part for item in bom_items] + # Filter + items = items.filter(part__in=allowed_parts) 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): initials = super().get_initial() @@ -558,11 +595,17 @@ class StockItemInstall(AjaxUpdateView): if items.count() == 1: item = items.first() initials['stock_item'] = item.pk - initials['quantity_to_install'] = item.quantity + # initials['quantity_to_install'] = item.quantity if 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 def get_form(self): @@ -575,6 +618,8 @@ class StockItemInstall(AjaxUpdateView): def post(self, request, *args, **kwargs): + self.get_params() + form = self.get_form() valid = form.is_valid() @@ -584,13 +629,20 @@ class StockItemInstall(AjaxUpdateView): data = form.cleaned_data other_stock_item = data['stock_item'] - quantity = data['quantity_to_install'] + # quantity = data['quantity_to_install'] + # Quantity will always be 1 for serialized item + quantity = 1 notes = data['notes'] - # Install the other stock item into this one + # Get stock item this_stock_item = self.get_object() - this_stock_item.installStockItem(other_stock_item, quantity, request.user, notes) + 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) data = { 'form_valid': valid, From 1c4924a4a5ebb95c52cc6a40c4c8959954d98544 Mon Sep 17 00:00:00 2001 From: eeintech Date: Mon, 2 Aug 2021 15:14:55 -0400 Subject: [PATCH 02/28] Style duh --- InvenTree/stock/forms.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index b088a6cdef..c0d6e9026f 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -8,7 +8,6 @@ from __future__ import unicode_literals from django import forms from django.forms.utils import ErrorDict from django.utils.translation import ugettext_lazy as _ -from django.core.validators import MinValueValidator from django.core.exceptions import ValidationError from mptt.fields import TreeNodeChoiceField @@ -242,8 +241,8 @@ class InstallStockForm(HelperForm): ) to_install = forms.BooleanField( - widget=forms.HiddenInput(), - required=False + widget=forms.HiddenInput(), + required=False, ) # quantity_to_install = RoundingDecimalFormField( From 172a08fbba3b100c09ca5875ee12507efb0661d5 Mon Sep 17 00:00:00 2001 From: eeintech Date: Tue, 3 Aug 2021 09:53:08 -0400 Subject: [PATCH 03/28] Removed old quantity setting lines --- InvenTree/stock/forms.py | 10 ---------- InvenTree/stock/views.py | 2 -- 2 files changed, 12 deletions(-) diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index c0d6e9026f..7e739306b0 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -245,16 +245,6 @@ class InstallStockForm(HelperForm): required=False, ) - # quantity_to_install = RoundingDecimalFormField( - # max_digits=10, decimal_places=5, - # initial=1, - # label=_('Quantity'), - # help_text=_('Stock quantity to assign'), - # validators=[ - # MinValueValidator(0.001) - # ] - # ) - notes = forms.CharField( required=False, help_text=_('Notes') diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 25f8cefac1..8214ae75b4 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -595,7 +595,6 @@ class StockItemInstall(AjaxUpdateView): if items.count() == 1: item = items.first() initials['stock_item'] = item.pk - # initials['quantity_to_install'] = item.quantity if self.part: initials['part'] = self.part @@ -629,7 +628,6 @@ class StockItemInstall(AjaxUpdateView): data = form.cleaned_data other_stock_item = data['stock_item'] - # quantity = data['quantity_to_install'] # Quantity will always be 1 for serialized item quantity = 1 notes = data['notes'] From 29c8daed0af0b2a9c06106e03b7bb03bc0a9724b Mon Sep 17 00:00:00 2001 From: eeintech Date: Tue, 3 Aug 2021 12:21:44 -0400 Subject: [PATCH 04/28] 'has_ipn' filter method did not return queryset --- InvenTree/part/api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 3612a1c9f9..c0d049ecc7 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -443,6 +443,8 @@ class PartFilter(rest_filters.FilterSet): else: queryset = queryset.filter(IPN='') + return queryset + # Regex filter for name name_regex = rest_filters.CharFilter(label='Filter by name (regex)', field_name='name', lookup_expr='iregex') From fa3c5ae1081c1b709db50fe82b3ec4d7c3a5edc0 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 4 Aug 2021 00:45:56 +0200 Subject: [PATCH 05/28] updating language to be clearer see https://github.com/inventree/InvenTree/issues/1889#issuecomment-891901070 --- InvenTree/part/templates/part/detail.html | 6 +++--- InvenTree/part/templates/part/prices.html | 2 +- InvenTree/templates/js/translated/bom.js | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 00d7f01e47..59aec17944 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -899,7 +899,7 @@ {% for line in price_history %}'{{ line.date }}',{% endfor %} ], datasets: [{ - label: '{% blocktrans %}Single Price - {{currency}}{% endblocktrans %}', + label: '{% blocktrans %}Purchase Unit Price - {{currency}}{% endblocktrans %}', backgroundColor: 'rgba(255, 99, 132, 0.2)', borderColor: 'rgb(255, 99, 132)', yAxisID: 'y', @@ -911,7 +911,7 @@ }, {% 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)', borderColor: 'rgb(68, 157, 68)', yAxisID: 'y2', @@ -923,7 +923,7 @@ hidden: true, }, { - label: '{% blocktrans %}Part Single Price - {{currency}}{% endblocktrans %}', + label: '{% blocktrans %}Supplier Unit Cost - {{currency}}{% endblocktrans %}', backgroundColor: 'rgba(70, 127, 155, 0.2)', borderColor: 'rgb(70, 127, 155)', yAxisID: 'y', diff --git a/InvenTree/part/templates/part/prices.html b/InvenTree/part/templates/part/prices.html index 7581d659e1..e498bc09ba 100644 --- a/InvenTree/part/templates/part/prices.html +++ b/InvenTree/part/templates/part/prices.html @@ -161,7 +161,7 @@

    {% trans 'Stock Pricing' %} + The Supplier Unit Cost is the current purchase price for that supplier part.">

    {% if price_history|length > 0 %}
    diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 32166d972a..20829bad79 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -262,13 +262,13 @@ function loadBomTable(table, options) { cols.push( { field: 'price_range', - title: '{% trans "Buy Price" %}', + title: '{% trans "Supplier Cost" %}', sortable: true, formatter: function(value, row, index, field) { if (value) { return value; } else { - return "{% trans 'No pricing available' %}"; + return "{% trans 'No supplier pricing available' %}"; } } }); From 83d8226ad6ecba3e9f1aea3a8199ef4c92c07e55 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 4 Aug 2021 11:33:20 +1000 Subject: [PATCH 06/28] Refactor "CreatePartCategory" form to API (cherry picked from commit 06ff961564cdbece29ea52f4e681c079d98bcec8) --- InvenTree/part/templates/part/category.html | 65 +++++---------------- InvenTree/part/test_views.py | 13 ----- InvenTree/part/urls.py | 3 - InvenTree/part/views.py | 44 -------------- InvenTree/templates/js/translated/part.js | 31 ++++++++++ 5 files changed, 46 insertions(+), 110 deletions(-) diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 0aee97a5e3..1c41092574 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -240,32 +240,20 @@ }); $("#cat-create").click(function() { - launchModalForm( - "{% url 'category-create' %}", - { - follow: true, - data: { - {% if category %} - category: {{ category.id }} - {% endif %} - }, - secondary: [ - { - field: 'default_location', - label: '{% trans "New Location" %}', - title: '{% trans "Create new location" %}', - url: "{% url 'stock-location-create' %}", - }, - { - field: 'parent', - label: '{% trans "New Category" %}', - title: '{% trans "Create new category" %}', - url: "{% url 'category-create' %}", - }, - ] - } - ); - }) + + var fields = categoryFields(); + + {% if category %} + fields.parent.value = {{ category.pk }}; + {% endif %} + + constructForm('{% url "api-part-category-list" %}', { + fields: fields, + method: 'POST', + title: '{% trans "Create Part Category" %}', + follow: true, + }); + }); $("#part-export").click(function() { @@ -286,12 +274,6 @@ {% endif %} }, secondary: [ - { - field: 'category', - label: '{% trans "New Category" %}', - title: '{% trans "Create new Part Category" %}', - url: "{% url 'category-create' %}", - }, { field: 'default_location', label: '{% trans "New Location" %}', @@ -307,24 +289,7 @@ {% if category %} $("#cat-edit").click(function () { - constructForm( - '{% 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 - } - ); + editCategory({{ category.pk }}); }); {% if category.parent %} diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py index 9779aac544..139ec20479 100644 --- a/InvenTree/part/test_views.py +++ b/InvenTree/part/test_views.py @@ -243,19 +243,6 @@ class PartQRTest(PartViewTestCase): class CategoryTest(PartViewTestCase): """ 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): """ Test that the "SetCategory" view works """ diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 2215e14785..62061f8279 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -65,9 +65,6 @@ category_parameter_urls = [ category_urls = [ - # Create a new category - url(r'^new/', views.CategoryCreate.as_view(), name='category-create'), - # Top level subcategory display url(r'^subcategory/', views.PartIndex.as_view(template_name='part/subcategory.html'), name='category-index-subcategory'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index fb69241a10..dd79a5360b 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -12,7 +12,6 @@ from django.db.utils import IntegrityError from django.shortcuts import get_object_or_404 from django.shortcuts import HttpResponseRedirect from django.utils.translation import gettext_lazy as _ -from django.urls import reverse, reverse_lazy from django.views.generic import DetailView, ListView from django.forms.models import model_to_dict from django.forms import HiddenInput, CheckboxInput @@ -1905,49 +1904,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): """ View for creating a new PartCategoryParameterTemplate """ diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 169c722d79..aaee9e47a0 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -14,6 +14,37 @@ function yesNoLabel(value) { } +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={}) { var url = `/api/part/${pk}/`; From 989983bdb5ead5e35b2a7f1e7b1fee11945d342f Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 4 Aug 2021 11:37:59 +1000 Subject: [PATCH 07/28] Fixed missing import --- InvenTree/part/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index dd79a5360b..dd2868b72b 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -12,6 +12,7 @@ from django.db.utils import IntegrityError from django.shortcuts import get_object_or_404 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 f95346f21458204530dc445a54d1c714ec168e63 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 4 Aug 2021 12:10:49 +1000 Subject: [PATCH 08/28] Make the part thumbnail selection window searchable --- InvenTree/part/api.py | 16 +++++++++++++--- InvenTree/part/templates/part/part_base.html | 10 +++++++--- InvenTree/templates/js/translated/tables.js | 2 +- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index c0d049ecc7..773631459c 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -338,9 +338,7 @@ class PartThumbs(generics.ListAPIView): - Images may be used for multiple parts! """ - queryset = self.get_queryset() - - # TODO - We should return the thumbnails here, not the full image! + queryset = self.filter_queryset(self.get_queryset()) # Return the most popular parts first data = queryset.values( @@ -349,6 +347,18 @@ class PartThumbs(generics.ListAPIView): return Response(data) + filter_backends = [ + filters.SearchFilter, + ] + + search_fields = [ + 'name', + 'description', + 'IPN', + 'revision', + 'keywords', + 'category__name', + ] class PartThumbsUpdate(generics.RetrieveUpdateAPIView): """ API endpoint for updating Part thumbnails""" diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 5191399f0a..ec637412a8 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -415,13 +415,16 @@ // Callback when the image-selection modal form is displayed // Populate the form with image data (requested via AJAX) - $("#modal-form").find("#image-select-table").bootstrapTable({ - pagination: true, - pageSize: 25, + $("#modal-form").find("#image-select-table").inventreeTable({ url: "{% url 'api-part-thumbs' %}", showHeader: false, + showColumns: false, clickToSelect: true, + sidePagination: 'server', singleSelect: true, + formatNoMatches: function() { + return '{% trans "No matching images found" %}'; + }, columns: [ { checkbox: true, @@ -429,6 +432,7 @@ { field: 'image', title: 'Image', + searchable: true, formatter: function(value, row, index, field) { return "" } diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js index afe1fefbc9..88d9a5f99a 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -187,7 +187,7 @@ $.fn.inventreeTable = function(options) { if (!options.disablePagination) { options.pagination = true; 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.totalField = 'count'; options.dataField = 'results'; From 56c0e289bd7544c1a9b3a87b79b954dca5b30f67 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 4 Aug 2021 12:13:24 +1000 Subject: [PATCH 09/28] Style fix --- InvenTree/part/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 773631459c..a01b05034f 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -360,6 +360,7 @@ class PartThumbs(generics.ListAPIView): 'category__name', ] + class PartThumbsUpdate(generics.RetrieveUpdateAPIView): """ API endpoint for updating Part thumbnails""" From 1f70538b043a03168e1666b01d8fe450a58025ee Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 4 Aug 2021 14:24:17 +1000 Subject: [PATCH 10/28] Adds a button to tables to reload data --- InvenTree/templates/js/translated/filters.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/InvenTree/templates/js/translated/filters.js b/InvenTree/templates/js/translated/filters.js index 4ee08affdf..bc0dc1b958 100644 --- a/InvenTree/templates/js/translated/filters.js +++ b/InvenTree/templates/js/translated/filters.js @@ -265,6 +265,8 @@ function setupFilterList(tableKey, table, target) { // One blank slate, please element.empty(); + element.append(``); + element.append(``); if (Object.keys(filters).length > 0) { @@ -279,6 +281,11 @@ function setupFilterList(tableKey, table, target) { element.append(`
    ${title} = ${value}x
    `); } + // Callback for reloading the table + element.find(`#reload-${tableKey}`).click(function() { + $(table).bootstrapTable('refresh'); + }); + // Add a callback for adding a new filter element.find(`#${add}`).click(function clicked() { From 75a1be0284244e79ba08d986c2957d9e852d4955 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 Aug 2021 17:25:51 +1000 Subject: [PATCH 11/28] Use API forms for creating and editing BomItem objects --- InvenTree/part/forms.py | 28 ----- InvenTree/part/templates/part/detail.html | 30 ++--- InvenTree/part/urls.py | 10 -- InvenTree/part/views.py | 128 ---------------------- InvenTree/templates/js/translated/bom.js | 35 ++++-- 5 files changed, 43 insertions(+), 188 deletions(-) diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 9523550198..1fc2848440 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -18,7 +18,6 @@ import common.models from common.forms import MatchItemForm from .models import Part, PartCategory, PartRelated -from .models import BomItem from .models import PartParameterTemplate, PartParameter from .models import PartCategoryParameterTemplate from .models import PartSellPriceBreak, PartInternalPriceBreak @@ -317,33 +316,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): """ Simple form for viewing part pricing information """ diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 59aec17944..267b880d49 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -440,22 +440,22 @@ }); $("#bom-item-new").click(function () { - launchModalForm( - "{% url 'bom-item-create' %}?parent={{ part.id }}", - { - success: function() { - $("#bom-table").bootstrapTable('refresh'); - }, - secondary: [ - { - field: 'sub_part', - label: '{% trans "New Part" %}', - title: '{% trans "Create New Part" %}', - url: "{% url 'part-create' %}", - }, - ] + + var fields = bomItemFields(); + + fields.part.value = {{ part.pk }}; + fields.sub_part.filters = { + active: true, + }; + + constructForm('{% url "api-bom-list" %}', { + fields: fields, + method: 'POST', + title: '{% trans "Create BOM Item" %}', + onSuccess: function() { + $('#bom-table').bootstrapTable('refresh'); } - ); + }); }); {% else %} diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 62061f8279..52e9b929c1 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -78,10 +78,6 @@ category_urls = [ ])) ] -part_bom_urls = [ - url(r'^edit/?', views.BomItemEdit.as_view(), name='bom-item-edit'), -] - # URL list for part web interface part_urls = [ @@ -92,9 +88,6 @@ part_urls = [ url(r'^import/', views.PartImport.as_view(), name='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 url(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='bom-upload-template'), @@ -122,9 +115,6 @@ part_urls = [ # Change category for multiple parts url(r'^set-category/?', views.PartSetCategory.as_view(), name='part-set-category'), - # Bom Items - url(r'^bom/(?P\d+)/', include(part_bom_urls)), - # Individual part using IPN as slug url(r'^(?P[-\w]+)/', views.PartDetailFromIPN.as_view(), name='part-detail-from-ipn'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index dd2868b72b..b35e752351 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -2078,134 +2078,6 @@ class CategoryParameterTemplateDelete(AjaxDeleteView): 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): """ View for creating a sale price break for a part diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 20829bad79..34a6206ac9 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -8,6 +8,26 @@ */ +function bomItemFields() { + + return { + part: { + hidden: true, + }, + sub_part: { + }, + quantity: {}, + reference: {}, + overage: {}, + note: {}, + allow_variants: {}, + inherited: {}, + optional: {}, + }; + +} + + function reloadBomTable(table, options) { table.bootstrapTable('refresh'); @@ -528,14 +548,15 @@ function loadBomTable(table, options) { var pk = $(this).attr('pk'); var url = `/part/bom/${pk}/edit/`; - launchModalForm( - url, - { - success: function() { - reloadBomTable(table); - } + var fields = bomItemFields(); + + constructForm(`/api/bom/${pk}/`, { + fields: fields, + title: '{% trans "Edit BOM Item" %}', + onSuccess: function() { + reloadBomTable(table); } - ); + }); }); table.on('click', '.bom-validate-button', function() { From 2e8a490ca9cbb18b67309da5c15b8d42103dbbb0 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 Aug 2021 17:41:47 +1000 Subject: [PATCH 12/28] Fixes for unit tests --- InvenTree/part/test_views.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py index 139ec20479..206d4dd56a 100644 --- a/InvenTree/part/test_views.py +++ b/InvenTree/part/test_views.py @@ -259,22 +259,3 @@ class CategoryTest(PartViewTestCase): response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') 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) From a64ee23afc2aaf01ab5578dd571248ee5b6538b2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 Aug 2021 23:16:11 +1000 Subject: [PATCH 13/28] Add more options for form rendering - "before" a field - "after" a field - pure "eye candy" field --- InvenTree/templates/js/translated/forms.js | 45 ++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 4801ec77eb..46b2b21a87 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -366,6 +366,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 +564,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.fields_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 +602,7 @@ function submitFormData(fields, options) { } else { console.log(`WARNING: Could not find field matching '${name}'`); } - }); + } var upload_func = inventreePut; @@ -1279,6 +1288,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 +1306,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 +1373,10 @@ function constructField(name, parameters, options) { html += `
    `; // controls html += `
    `; // form-group + if (parameters.after) { + html += parameters.after; + } + return html; } @@ -1430,6 +1455,9 @@ function constructInput(name, parameters, options) { case 'date': func = constructDateInput; break; + case 'candy': + func = constructCandyInput; + break; default: // Unsupported field type! break; @@ -1658,6 +1686,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 * From 2bf3e3ab020a9030dd73d51c11ac426437d53a60 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 Aug 2021 23:26:17 +1000 Subject: [PATCH 14/28] Function to construct part form fields --- InvenTree/part/api.py | 2 + InvenTree/templates/js/translated/forms.js | 2 +- InvenTree/templates/js/translated/part.js | 110 +++++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index a01b05034f..3b91d27c81 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 diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 46b2b21a87..27337d97e7 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -564,7 +564,7 @@ function submitFormData(fields, options) { var has_files = false; // Extract values for each field - for (var idx = 0; idx < options.fields_names.length; idx++) { + for (var idx = 0; idx < options.field_names.length; idx++) { var name = options.field_names[idx]; diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index aaee9e47a0..fafdaa94e7 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -13,6 +13,116 @@ 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, + }, + }; + + // 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_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 { From b04f22fc53848981c1a581f73ae21312eb9a43a6 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 Aug 2021 23:27:16 +1000 Subject: [PATCH 15/28] CreatePart form now uses the API - Simplify the way category parameter templates are copied --- InvenTree/common/models.py | 3 +- InvenTree/part/api.py | 35 ++++- InvenTree/part/models.py | 48 +++--- InvenTree/part/templates/part/category.html | 38 ++--- InvenTree/part/test_views.py | 13 -- InvenTree/part/urls.py | 3 - InvenTree/part/views.py | 161 +------------------- 7 files changed, 74 insertions(+), 227 deletions(-) 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 3b91d27c81..88866ad58c 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -630,16 +630,47 @@ 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 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/models.py b/InvenTree/part/models.py index 2dd5d3ad7f..b75edde9cc 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -409,7 +409,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 +437,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/test_views.py b/InvenTree/part/test_views.py index 206d4dd56a..c555687183 100644 --- a/InvenTree/part/test_views.py +++ b/InvenTree/part/test_views.py @@ -158,19 +158,6 @@ class PartDetailTest(PartViewTestCase): 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 """ diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 52e9b929c1..0802a94f1a 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -81,9 +81,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..3e4b6c59d7 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -44,7 +44,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 @@ -438,165 +438,6 @@ class PartDuplicate(AjaxCreateView): 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' From 1fafaf857720ef578392e0444b963b1c5abd1208 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 Aug 2021 23:29:39 +1000 Subject: [PATCH 16/28] Refactor partfields function (was essentially duplicated) --- InvenTree/templates/js/translated/part.js | 81 +++-------------------- 1 file changed, 8 insertions(+), 73 deletions(-) diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index fafdaa94e7..988481d77c 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -65,6 +65,11 @@ function partFields(options={}) { }, }; + // 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"]; @@ -159,79 +164,9 @@ 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, From 408ff639ddb18e6c0d539aed7754f759dcea1bb3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 Aug 2021 23:48:21 +1000 Subject: [PATCH 17/28] Adds ability to pre-fill a form with a complete dataset --- InvenTree/templates/js/translated/forms.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 27337d97e7..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) { From 2cb0b448b77b2dd429167c38f4d4ec60e37a1871 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 5 Aug 2021 00:15:55 +1000 Subject: [PATCH 18/28] Fix error message styles for API errors - django ValidationError uses "__all__" key for non_field_errors - whyyyyyyyyyyyy --- InvenTree/InvenTree/serializers.py | 16 ++++++++++++--- InvenTree/templates/js/translated/part.js | 25 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) 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/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 988481d77c..f8b410c9c0 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -173,7 +173,32 @@ function editPart(pk, options={}) { title: '{% trans "Edit Part" %}', reload: true, }); +} + +function duplicatePart(pk, options={}) { + + // First we need all the part information + inventreeGet(`/api/part/${pk}/`, {}, { + + success: function(response) { + + var fields = partFields({ + duplicate: true + }); + + constructForm('{% url "api-part-list" %}', { + method: 'POST', + fields: fields, + title: '{% trans "Duplicate Part" %}', + data: response, + onSuccess: function(data) { + // Follow the new part + location.href = `/part/${data.pk}/`; + } + }); + } + }); } From 0e8fb6a5ad6df073770c243f9af96336f920365f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 5 Aug 2021 00:16:42 +1000 Subject: [PATCH 19/28] Refactored DuplicatePart form - API endpoint now takes care of duplication of other data --- InvenTree/part/api.py | 28 +++++ InvenTree/part/templates/part/part_base.html | 7 +- InvenTree/part/urls.py | 1 - InvenTree/part/views.py | 124 ------------------- InvenTree/templates/js/translated/part.js | 15 ++- 5 files changed, 43 insertions(+), 132 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 88866ad58c..789ba9b9b7 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -651,6 +651,34 @@ class PartList(generics.ListCreateAPIView): 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)) 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/urls.py b/InvenTree/part/urls.py index 0802a94f1a..53d28f7ccb 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -40,7 +40,6 @@ 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'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 3e4b6c59d7..c4ae2aee77 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -314,130 +314,6 @@ class MakePartVariant(AjaxCreateView): 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 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/part.js b/InvenTree/templates/js/translated/part.js index f8b410c9c0..a1d40f7bf4 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -110,6 +110,19 @@ function partFields(options={}) { 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" %}', @@ -184,7 +197,7 @@ function duplicatePart(pk, options={}) { success: function(response) { var fields = partFields({ - duplicate: true + duplicate: pk, }); constructForm('{% url "api-part-list" %}', { From aa4ed9feb07c1f32ab45e99f3d0de8e6aa2870ee Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 5 Aug 2021 00:24:38 +1000 Subject: [PATCH 20/28] Refactor MakeVariant form - Now is essentially identical to the DuplicatePart form - Uses the API form structure --- InvenTree/part/forms.py | 76 --------------------- InvenTree/part/templates/part/detail.html | 7 +- InvenTree/part/test_views.py | 19 ------ InvenTree/part/urls.py | 2 +- InvenTree/part/views.py | 81 ----------------------- InvenTree/templates/js/translated/part.js | 12 +++- 6 files changed, 15 insertions(+), 182 deletions(-) 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/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/test_views.py b/InvenTree/part/test_views.py index c555687183..5f2a9b1583 100644 --- a/InvenTree/part/test_views.py +++ b/InvenTree/part/test_views.py @@ -155,25 +155,6 @@ class PartDetailTest(PartViewTestCase): self.assertIn('streaming_content', dir(response)) -class PartTests(PartViewTestCase): - """ Tests for Part forms """ - - 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 53d28f7ccb..13fc6f7c16 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -40,7 +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'^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'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index c4ae2aee77..e805e8f260 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -233,87 +233,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 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/part.js b/InvenTree/templates/js/translated/part.js index a1d40f7bf4..3def7abdad 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -189,22 +189,30 @@ function editPart(pk, options={}) { } +// Launch form to duplicate a part function duplicatePart(pk, options={}) { // First we need all the part information inventreeGet(`/api/part/${pk}/`, {}, { - success: function(response) { + 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: response, + data: data, onSuccess: function(data) { // Follow the new part location.href = `/part/${data.pk}/`; From dd78464a749c51d44f588f320ce7ad9f196a7c59 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 5 Aug 2021 00:25:47 +1000 Subject: [PATCH 21/28] remove unused function --- InvenTree/part/models.py | 51 ------------------------------------- InvenTree/part/test_part.py | 8 +----- InvenTree/part/views.py | 4 +-- 3 files changed, 2 insertions(+), 61 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index b75edde9cc..89e92115ca 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -235,57 +235,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. diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index e30c80549f..b32b30a22e 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) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index e805e8f260..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 From aaf394ca7a1698ea6839fb7511485a1f85480f9f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 5 Aug 2021 00:26:21 +1000 Subject: [PATCH 22/28] PEP fixes --- InvenTree/part/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 89e92115ca..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 From 6acff2a26e8f6a578207d16ef41b61bf1b04f415 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 5 Aug 2021 00:40:02 +1000 Subject: [PATCH 23/28] Fixes unit test --- InvenTree/part/test_part.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index b32b30a22e..1e831601a4 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -287,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) From 655e5692e98d55c12dd122d8513ef3346199e9da Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 5 Aug 2021 00:58:07 +1000 Subject: [PATCH 24/28] More unit test fixes --- InvenTree/part/test_part.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 1e831601a4..1bd9fdf87d 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -275,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()) From c7712d4235ef06ade7baf29f40bfdc77706dc859 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 5 Aug 2021 01:13:48 +1000 Subject: [PATCH 25/28] even more unit tests --- InvenTree/part/test_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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( From c0ccb8f588634f07a94209eea2c1dc58a9002c61 Mon Sep 17 00:00:00 2001 From: eeintech Date: Wed, 4 Aug 2021 17:11:35 -0400 Subject: [PATCH 26/28] Fixed typo for build responsible column header --- InvenTree/templates/js/translated/build.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 4b8cd47eb5..26f3876af3 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -927,7 +927,7 @@ function loadBuildTable(table, options) { }, { field: 'responsible', - title: '{% trans "Resposible" %}', + title: '{% trans "Responsible" %}', sortable: true, formatter: function(value, row, index, field) { if (value) From fa6daeb679a1beaea293dc9b90ce22031f0c3163 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 5 Aug 2021 09:47:15 +1000 Subject: [PATCH 27/28] Pin weasyprint version to 52.5 --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index abcf2cb098..839237c6a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,8 @@ coverage==5.3 # Unit test coverage coveralls==2.1.2 # Coveralls linking (for Travis) rapidfuzz==0.7.6 # Fuzzy string matching 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-admin-shell==0.1.2 # Python shell for the admin interface py-moneyed==0.8.0 # Specific version requirement for py-moneyed From 00ffab472c7fbfe368a463ccfa4d99fd606d5674 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 5 Aug 2021 10:44:47 +1000 Subject: [PATCH 28/28] Fix for build report template --- .../report/templates/report/inventree_build_order_base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/report/templates/report/inventree_build_order_base.html b/InvenTree/report/templates/report/inventree_build_order_base.html index 5a0cbde93c..2d2d2766bb 100644 --- a/InvenTree/report/templates/report/inventree_build_order_base.html +++ b/InvenTree/report/templates/report/inventree_build_order_base.html @@ -79,7 +79,7 @@ content: "v{{report_revision}} - {{ date.isoformat }}"; {% block header_content %} - +