From cedf9a91085af9648f51881c8e579641b3a1973c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 13 May 2020 10:16:26 +1000 Subject: [PATCH 01/52] Attachment comment field is optional --- InvenTree/InvenTree/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index e192e34a0f..5ec2e2945d 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -56,7 +56,7 @@ class InvenTreeAttachment(models.Model): attachment = models.FileField(upload_to=rename_attachment, help_text=_('Select file to attach')) - comment = models.CharField(max_length=100, help_text=_('File comment')) + comment = models.CharField(blank=True, max_length=100, help_text=_('File comment')) user = models.ForeignKey( User, From db01f3646ae2e3ab3fb527a8361a52e4f8aae095 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 13 May 2020 11:13:26 +1000 Subject: [PATCH 02/52] Enable drag-and-drop attachment upload for Part --- .../InvenTree/static/script/inventree/inventree.js | 8 ++++++++ InvenTree/part/templates/part/attachments.html | 14 ++++++++++++++ InvenTree/templates/attachment_table.html | 4 +++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index 5995034241..45565f1d6a 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -140,10 +140,13 @@ function enableDragAndDrop(element, url, options) { url - URL to POST the file to options - object with following possible values: label - Label of the file to upload (default='file') + data - Other form data to upload success - Callback function in case of success error - Callback function in case of error */ + data = options.data || {}; + $(element).on('drop', function(event) { var transfer = event.originalEvent.dataTransfer; @@ -152,6 +155,11 @@ function enableDragAndDrop(element, url, options) { var formData = new FormData(); + // Add the extra data + for (var key in data) { + formData.append(key, data[key]); + } + if (isFileTransfer(transfer)) { formData.append(label, transfer.files[0]); diff --git a/InvenTree/part/templates/part/attachments.html b/InvenTree/part/templates/part/attachments.html index 965645b748..049ef0cd7a 100644 --- a/InvenTree/part/templates/part/attachments.html +++ b/InvenTree/part/templates/part/attachments.html @@ -16,6 +16,20 @@ {% block js_ready %} {{ block.super }} + enableDragAndDrop( + '#attachment-dropzone', + "{% url 'part-attachment-create' %}", + { + data: { + part: {{ part.id }}, + }, + label: 'attachment', + success: function(data, status, xhr) { + location.reload(); + } + } + ); + $("#new-attachment").click(function() { launchModalForm("{% url 'part-attachment-create' %}?part={{ part.id }}", { diff --git a/InvenTree/templates/attachment_table.html b/InvenTree/templates/attachment_table.html index 090ae566f6..71664a3ccc 100644 --- a/InvenTree/templates/attachment_table.html +++ b/InvenTree/templates/attachment_table.html @@ -6,6 +6,7 @@ +
@@ -37,4 +38,5 @@ {% endfor %} -
\ No newline at end of file + +
\ No newline at end of file From 7143c32fc95b6521dc92f41c3682424f56023fb1 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 13 May 2020 11:22:57 +1000 Subject: [PATCH 03/52] Drag-and-drop attachments for stock item --- InvenTree/InvenTree/static/css/inventree.css | 2 ++ .../stock/templates/stock/item_attachments.html | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index a1358341cc..7861be295a 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -346,9 +346,11 @@ z-index: 2; } +/* .dropzone * { pointer-events: none; } +*/ .dragover { background-color: #55A; diff --git a/InvenTree/stock/templates/stock/item_attachments.html b/InvenTree/stock/templates/stock/item_attachments.html index 2d056afbcf..453ab7f09b 100644 --- a/InvenTree/stock/templates/stock/item_attachments.html +++ b/InvenTree/stock/templates/stock/item_attachments.html @@ -17,6 +17,20 @@ {% block js_ready %} {{ block.super }} +enableDragAndDrop( + '#attachment-dropzone', + "{% url 'stock-item-attachment-create' %}", + { + data: { + stock_item: {{ item.id }}, + }, + label: 'attachment', + success: function(data, status, xhr) { + location.reload(); + } + } + ); + $("#new-attachment").click(function() { launchModalForm("{% url 'stock-item-attachment-create' %}?item={{ item.id }}", { From 30b5f7d507961b370716740d3a9fcac475470515 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 13 May 2020 11:28:45 +1000 Subject: [PATCH 04/52] Drag and drop for order attachments --- .../migrations/0035_auto_20200513_0016.py | 23 +++++++++++++++++++ .../order/templates/order/po_attachments.html | 14 +++++++++++ .../order/templates/order/so_attachments.html | 14 +++++++++++ InvenTree/order/views.py | 10 ++++++-- .../migrations/0038_auto_20200513_0016.py | 18 +++++++++++++++ .../migrations/0039_auto_20200513_0016.py | 18 +++++++++++++++ 6 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 InvenTree/order/migrations/0035_auto_20200513_0016.py create mode 100644 InvenTree/part/migrations/0038_auto_20200513_0016.py create mode 100644 InvenTree/stock/migrations/0039_auto_20200513_0016.py diff --git a/InvenTree/order/migrations/0035_auto_20200513_0016.py b/InvenTree/order/migrations/0035_auto_20200513_0016.py new file mode 100644 index 0000000000..101a55764e --- /dev/null +++ b/InvenTree/order/migrations/0035_auto_20200513_0016.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.5 on 2020-05-13 00:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0034_auto_20200512_1054'), + ] + + operations = [ + migrations.AlterField( + model_name='purchaseorderattachment', + name='comment', + field=models.CharField(blank=True, help_text='File comment', max_length=100), + ), + migrations.AlterField( + model_name='salesorderattachment', + name='comment', + field=models.CharField(blank=True, help_text='File comment', max_length=100), + ), + ] diff --git a/InvenTree/order/templates/order/po_attachments.html b/InvenTree/order/templates/order/po_attachments.html index a08b618fca..f5421e8760 100644 --- a/InvenTree/order/templates/order/po_attachments.html +++ b/InvenTree/order/templates/order/po_attachments.html @@ -20,6 +20,20 @@ {% block js_ready %} {{ block.super }} +enableDragAndDrop( + '#attachment-dropzone', + "{% url 'po-attachment-create' %}", + { + data: { + order: {{ order.id }}, + }, + label: 'attachment', + success: function(data, status, xhr) { + location.reload(); + } + } +); + $("#new-attachment").click(function() { launchModalForm("{% url 'po-attachment-create' %}?order={{ order.id }}", { diff --git a/InvenTree/order/templates/order/so_attachments.html b/InvenTree/order/templates/order/so_attachments.html index aff62213e5..672d9af952 100644 --- a/InvenTree/order/templates/order/so_attachments.html +++ b/InvenTree/order/templates/order/so_attachments.html @@ -19,6 +19,20 @@ {% block js_ready %} {{ block.super }} +enableDragAndDrop( + '#attachment-dropzone', + "{% url 'so-attachment-create' %}", + { + data: { + order: {{ order.id }}, + }, + label: 'attachment', + success: function(data, status, xhr) { + location.reload(); + } + } +); + $("#new-attachment").click(function() { launchModalForm("{% url 'so-attachment-create' %}?order={{ order.id }}", { diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 370c629cac..41a5b231a7 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -112,7 +112,10 @@ class PurchaseOrderAttachmentCreate(AjaxCreateView): initials = super(AjaxCreateView, self).get_initial() - initials["order"] = PurchaseOrder.objects.get(id=self.request.GET.get('order', -1)) + try: + initials["order"] = PurchaseOrder.objects.get(id=self.request.GET.get('order', -1)) + except (ValueError, PurchaseOrder.DoesNotExist): + pass return initials @@ -149,7 +152,10 @@ class SalesOrderAttachmentCreate(AjaxCreateView): def get_initial(self): initials = super().get_initial().copy() - initials['order'] = SalesOrder.objects.get(id=self.request.GET.get('order', None)) + try: + initials['order'] = SalesOrder.objects.get(id=self.request.GET.get('order', None)) + except (ValueError, SalesOrder.DoesNotExist): + pass return initials diff --git a/InvenTree/part/migrations/0038_auto_20200513_0016.py b/InvenTree/part/migrations/0038_auto_20200513_0016.py new file mode 100644 index 0000000000..a472480186 --- /dev/null +++ b/InvenTree/part/migrations/0038_auto_20200513_0016.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.5 on 2020-05-13 00:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0037_partattachment_upload_date'), + ] + + operations = [ + migrations.AlterField( + model_name='partattachment', + name='comment', + field=models.CharField(blank=True, help_text='File comment', max_length=100), + ), + ] diff --git a/InvenTree/stock/migrations/0039_auto_20200513_0016.py b/InvenTree/stock/migrations/0039_auto_20200513_0016.py new file mode 100644 index 0000000000..dacf666779 --- /dev/null +++ b/InvenTree/stock/migrations/0039_auto_20200513_0016.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.5 on 2020-05-13 00:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0038_stockitemattachment_upload_date'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitemattachment', + name='comment', + field=models.CharField(blank=True, help_text='File comment', max_length=100), + ), + ] From 41eff97c7ce92097a50c735d385aacf63e9ef039 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 14 May 2020 14:59:49 +1000 Subject: [PATCH 05/52] Add function to "increment" a number or a number-like string - Observe string width - Keep prefix if one exists --- InvenTree/InvenTree/helpers.py | 53 ++++++++++++++++++++++++++++++++++ InvenTree/InvenTree/tests.py | 23 +++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index b9a4d73740..8c5d59181d 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -120,6 +120,59 @@ def normalize(d): return d.quantize(Decimal(1)) if d == d.to_integral() else d.normalize() +def increment(n): + """ + Attempt to increment an integer (or a string that looks like an integer!) + + e.g. + + 001 -> 002 + 2 -> 3 + AB01 -> AB02 + QQQ -> QQQ + + """ + + value = str(n).strip() + + # Ignore empty strings + if not value: + return value + + pattern = r"(.*?)(\d+)?$" + + result = re.search(pattern, value) + + # No match! + if result is None: + return value + + groups = result.groups() + + # If we cannot match the regex, then simply return the provided value + if not len(groups) == 2: + return value + + prefix, number = groups + + # No number extracted? Simply return the prefix (without incrementing!) + if not number: + return prefix + + # Record the width of the number + width = len(number) + + try: + number = int(number) + 1 + number = str(number) + except ValueError: + pass + + number = number.zfill(width) + + return prefix + number + + def decimal2string(d): """ Format a Decimal number as a string, diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 203748de3e..877adab919 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -108,6 +108,29 @@ class TestQuoteWrap(TestCase): self.assertEqual(helpers.WrapWithQuotes('hello"'), '"hello"') +class TestIncrement(TestCase): + + def tests(self): + """ Test 'intelligent' incrementing function """ + + tests = [ + ("", ""), + (1, "2"), + ("001", "002"), + ("1001", "1002"), + ("ABC123", "ABC124"), + ("XYZ0", "XYZ1"), + ("123Q", "123Q"), + ("QQQ", "QQQ"), + ] + + for test in tests: + a, b = test + + result = helpers.increment(a) + self.assertEqual(result, b) + + class TestMakeBarcode(TestCase): """ Tests for barcode string creation """ From cebfe9a30f0cecb350efbdc238aa17a8371dab6f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 14 May 2020 15:00:00 +1000 Subject: [PATCH 06/52] Function to predict the next purchase order number --- InvenTree/order/fixtures/order.yaml | 4 +-- InvenTree/order/models.py | 40 ++++++++++++++++++++++++++++- InvenTree/order/tests.py | 6 +++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/InvenTree/order/fixtures/order.yaml b/InvenTree/order/fixtures/order.yaml index 0ba4bdbeb5..ed4eae84eb 100644 --- a/InvenTree/order/fixtures/order.yaml +++ b/InvenTree/order/fixtures/order.yaml @@ -4,7 +4,7 @@ - model: order.purchaseorder pk: 1 fields: - reference: 0001 + reference: '0001' description: "Ordering some screws" supplier: 1 @@ -12,7 +12,7 @@ - model: order.purchaseorder pk: 2 fields: - reference: 0002 + reference: '0002' description: "Ordering some more screws" supplier: 3 diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 56da6c783d..5e6c5e521d 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -24,7 +24,7 @@ from stock import models as stock_models from company.models import Company, SupplierPart from InvenTree.fields import RoundingDecimalField -from InvenTree.helpers import decimal2string +from InvenTree.helpers import decimal2string, increment from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus from InvenTree.models import InvenTreeAttachment @@ -96,6 +96,44 @@ class PurchaseOrder(Order): ORDER_PREFIX = "PO" + @classmethod + def getNextOrderNumber(cls): + """ + Try to predict the next order-number + """ + + if PurchaseOrder.objects.count() == 0: + return None + + # We will assume that the latest pk has the highest PO number + order = PurchaseOrder.objects.last() + ref = order.reference + + if not ref: + return None + + tries = set() + + tries.add(ref) + + while 1: + new_ref = increment(ref) + + if new_ref in tries: + # We are in a looping situation - simply return the original one + return ref + + # Check that the new ref does not exist in the database + if PurchaseOrder.objects.filter(reference=new_ref).exists(): + tries.add(new_ref) + new_ref = increment(new_ref) + + else: + break + + return new_ref + + def __str__(self): return "PO {ref} - {company}".format(ref=self.reference, company=self.supplier.name) diff --git a/InvenTree/order/tests.py b/InvenTree/order/tests.py index ca24b9586d..89c39db8f5 100644 --- a/InvenTree/order/tests.py +++ b/InvenTree/order/tests.py @@ -37,6 +37,12 @@ class OrderTest(TestCase): self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO 1 - ACME)") + def test_increment(self): + + next_ref = PurchaseOrder.getNextOrderNumber() + + self.assertEqual(next_ref, '0003') + def test_on_order(self): """ There should be 3 separate items on order for the M2x4 LPHS part """ From 08903f357ec70e1d2e4b56a40f9c5488dc8f3965 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 14 May 2020 15:02:46 +1000 Subject: [PATCH 07/52] Auto-increment the purchase-order number when creating a new one --- InvenTree/order/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 41a5b231a7..c8a78a8fa9 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -290,6 +290,7 @@ class PurchaseOrderCreate(AjaxCreateView): def get_initial(self): initials = super().get_initial().copy() + initials['reference'] = PurchaseOrder.getNextOrderNumber() initials['status'] = PurchaseOrderStatus.PENDING supplier_id = self.request.GET.get('supplier', None) From b619f260746f254f05254b3817d55b7eee2d2ee6 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 14 May 2020 15:05:55 +1000 Subject: [PATCH 08/52] Auto-increment sales order reference number --- InvenTree/order/models.py | 75 +++++++++++++++++++-------------------- InvenTree/order/views.py | 5 +-- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 5e6c5e521d..2ebc6a6793 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -49,6 +49,43 @@ class Order(models.Model): ORDER_PREFIX = "" + @classmethod + def getNextOrderNumber(cls): + """ + Try to predict the next order-number + """ + + if cls.objects.count() == 0: + return None + + # We will assume that the latest pk has the highest PO number + order = cls.objects.last() + ref = order.reference + + if not ref: + return None + + tries = set() + + tries.add(ref) + + while 1: + new_ref = increment(ref) + + if new_ref in tries: + # We are in a looping situation - simply return the original one + return ref + + # Check that the new ref does not exist in the database + if cls.objects.filter(reference=new_ref).exists(): + tries.add(new_ref) + new_ref = increment(new_ref) + + else: + break + + return new_ref + def __str__(self): el = [] @@ -96,44 +133,6 @@ class PurchaseOrder(Order): ORDER_PREFIX = "PO" - @classmethod - def getNextOrderNumber(cls): - """ - Try to predict the next order-number - """ - - if PurchaseOrder.objects.count() == 0: - return None - - # We will assume that the latest pk has the highest PO number - order = PurchaseOrder.objects.last() - ref = order.reference - - if not ref: - return None - - tries = set() - - tries.add(ref) - - while 1: - new_ref = increment(ref) - - if new_ref in tries: - # We are in a looping situation - simply return the original one - return ref - - # Check that the new ref does not exist in the database - if PurchaseOrder.objects.filter(reference=new_ref).exists(): - tries.add(new_ref) - new_ref = increment(new_ref) - - else: - break - - return new_ref - - def __str__(self): return "PO {ref} - {company}".format(ref=self.reference, company=self.supplier.name) diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index c8a78a8fa9..0585c34cd7 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -29,7 +29,7 @@ from . import forms as order_forms from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.helpers import DownloadFile, str2bool -from InvenTree.status_codes import PurchaseOrderStatus, StockStatus +from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus logger = logging.getLogger(__name__) @@ -321,7 +321,8 @@ class SalesOrderCreate(AjaxCreateView): def get_initial(self): initials = super().get_initial().copy() - initials['status'] = PurchaseOrderStatus.PENDING + initials['reference'] = SalesOrder.getNextOrderNumber() + initials['status'] = SalesOrderStatus.PENDING customer_id = self.request.GET.get('customer', None) From d3758981b43b054946ec783562f05f49d92d4b22 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 14 May 2020 15:09:31 +1000 Subject: [PATCH 09/52] Fix "New Sales Order" button in Customer detail view --- InvenTree/company/templates/company/sales_orders.html | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/InvenTree/company/templates/company/sales_orders.html b/InvenTree/company/templates/company/sales_orders.html index facfbce189..0b64bed2f5 100644 --- a/InvenTree/company/templates/company/sales_orders.html +++ b/InvenTree/company/templates/company/sales_orders.html @@ -33,9 +33,16 @@ } }); - $("#new-sales-order").click(function() { - // TODO - Create a new sales order + launchModalForm( + "{% url 'so-create' %}", + { + data: { + customer: {{ company.id }}, + }, + follow: true, + }, + ); }); {% endblock %} \ No newline at end of file From 6175c5408c9a9b2e6f848c4ecceae13945521e6c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 14 May 2020 15:10:48 +1000 Subject: [PATCH 10/52] Javascript indent cleanup --- .../templates/company/company_base.html | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html index da73a7b7b3..9de0e9e764 100644 --- a/InvenTree/company/templates/company/company_base.html +++ b/InvenTree/company/templates/company/company_base.html @@ -85,10 +85,10 @@ InvenTree | {% trans "Company" %} - {{ company.name }} $('#company-edit').click(function() { launchModalForm( - "{% url 'company-edit' company.id %}", - { - reload: true - }); + "{% url 'company-edit' company.id %}", + { + reload: true + }); }); $("#company-order-2").click(function() { @@ -104,10 +104,10 @@ InvenTree | {% trans "Company" %} - {{ company.name }} $('#company-delete').click(function() { launchModalForm( - "{% url 'company-delete' company.id %}", - { - redirect: "{% url 'company-index' %}" - }); + "{% url 'company-delete' company.id %}", + { + redirect: "{% url 'company-index' %}" + }); }); enableDragAndDrop( @@ -123,11 +123,11 @@ InvenTree | {% trans "Company" %} - {{ company.name }} $("#company-thumb").click(function() { launchModalForm( - "{% url 'company-image' company.id %}", - { - reload: true - } - ); + "{% url 'company-image' company.id %}", + { + reload: true + } + ); }); {% endblock %} \ No newline at end of file From 5167f542686dcdceb0f05798e3afd6918982c48d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 14 May 2020 15:20:40 +1000 Subject: [PATCH 11/52] Fix unit tests --- InvenTree/order/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/order/tests.py b/InvenTree/order/tests.py index 89c39db8f5..7b118dc60e 100644 --- a/InvenTree/order/tests.py +++ b/InvenTree/order/tests.py @@ -31,11 +31,11 @@ class OrderTest(TestCase): self.assertEqual(order.get_absolute_url(), '/order/purchase-order/1/') - self.assertEqual(str(order), 'PO 1 - ACME') + self.assertEqual(str(order), 'PO 0001 - ACME') line = PurchaseOrderLineItem.objects.get(pk=1) - self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO 1 - ACME)") + self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO 0001 - ACME)") def test_increment(self): From 16aa18429a4123022fa4837853c030e026f25ff1 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 14 May 2020 16:55:33 +1000 Subject: [PATCH 12/52] Use bootstrap3 styling for django crispy forms --- InvenTree/InvenTree/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 797924fd36..dda110e834 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -341,7 +341,7 @@ if DEBUG: print("STATIC_ROOT:", STATIC_ROOT) # crispy forms use the bootstrap templates -CRISPY_TEMPLATE_PACK = 'bootstrap' +CRISPY_TEMPLATE_PACK = 'bootstrap3' # Use database transactions when importing / exporting data IMPORT_EXPORT_USE_TRANSACTIONS = True From 007d2d4054e3519cac392d7f5f2cd1bdc5799130 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 14 May 2020 21:51:44 +1000 Subject: [PATCH 13/52] Add prepended text to order references for forms --- InvenTree/InvenTree/forms.py | 12 ++++++++++++ InvenTree/order/forms.py | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index f0dd5f3d43..c794f7ca25 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -7,6 +7,8 @@ from __future__ import unicode_literals from django import forms from crispy_forms.helper import FormHelper +from crispy_forms.layout import Field, Layout +from crispy_forms.bootstrap import PrependedText from django.contrib.auth.models import User @@ -19,6 +21,16 @@ class HelperForm(forms.ModelForm): self.helper.form_tag = False + """ + Create a default 'layout' for this form. + Ref: https://django-crispy-forms.readthedocs.io/en/latest/layouts.html + This is required to do fancy things later (like adding PrependedText, etc). + + Simply create a 'blank' layout for each available field. + """ + + self.helper.layout = Layout(*self.fields.keys()) + class DeleteForm(forms.Form): """ Generic deletion form which provides simple user confirmation diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 9991fe6670..e7e26064f8 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -8,6 +8,9 @@ from __future__ import unicode_literals from django import forms from django.utils.translation import ugettext as _ +from crispy_forms.layout import Field, Layout +from crispy_forms.bootstrap import PrependedText + from mptt.fields import TreeNodeChoiceField from InvenTree.forms import HelperForm @@ -88,6 +91,23 @@ class ReceivePurchaseOrderForm(HelperForm): class EditPurchaseOrderForm(HelperForm): """ Form for editing a PurchaseOrder object """ + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + # TODO - Refactor this? + self.helper.layout = Layout( + Field(PrependedText( + 'reference', + 'PO', + placeholder=_("Purchase Order") + )), + Field('supplier'), + Field('supplier_reference'), + Field('description'), + Field('link'), + ) + class Meta: model = PurchaseOrder fields = [ @@ -102,6 +122,23 @@ class EditPurchaseOrderForm(HelperForm): class EditSalesOrderForm(HelperForm): """ Form for editing a SalesOrder object """ + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + # TODO - Refactor? + self.helper.layout = Layout( + Field(PrependedText( + 'reference', + 'SO', + placeholder=_("Sales Order") + )), + Field('customer'), + Field('customer_reference'), + Field('description'), + Field('link'), + ) + class Meta: model = SalesOrder fields = [ From 0f00205256a7366982351ad9cf065aa2d9036bfc Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 14 May 2020 21:52:39 +1000 Subject: [PATCH 14/52] PEP fix --- InvenTree/InvenTree/forms.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index c794f7ca25..3d8871f824 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -7,8 +7,7 @@ from __future__ import unicode_literals from django import forms from crispy_forms.helper import FormHelper -from crispy_forms.layout import Field, Layout -from crispy_forms.bootstrap import PrependedText +from crispy_forms.layout import Layout from django.contrib.auth.models import User @@ -30,7 +29,7 @@ class HelperForm(forms.ModelForm): """ self.helper.layout = Layout(*self.fields.keys()) - + class DeleteForm(forms.Form): """ Generic deletion form which provides simple user confirmation From 72cfaccac574814e6e201706d117bb2295f9f29d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 15 May 2020 21:16:00 +1000 Subject: [PATCH 15/52] Pass StockItem object through to the SerializeStock form --- InvenTree/stock/forms.py | 15 ++++++++++++++- InvenTree/stock/views.py | 13 ++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index a4578440cb..0286054766 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -13,6 +13,8 @@ from mptt.fields import TreeNodeChoiceField from InvenTree.helpers import GetExportFormats from InvenTree.forms import HelperForm +from InvenTree.fields import RoundingDecimalFormField + from .models import StockLocation, StockItem, StockItemTracking, StockItemAttachment @@ -79,7 +81,7 @@ class CreateStockItemForm(HelperForm): self._clean_form() -class SerializeStockForm(forms.ModelForm): +class SerializeStockForm(HelperForm): """ Form for serializing a StockItem. """ destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label='Destination', required=True, help_text='Destination for serialized stock (by default, will remain in current location)') @@ -88,6 +90,17 @@ class SerializeStockForm(forms.ModelForm): note = forms.CharField(label='Notes', required=False, help_text='Add transaction note (optional)') + quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5) + + def __init__(self, *args, **kwargs): + + # Extract the stock item + stock_item = kwargs.pop('item') + + super().__init__(*args, **kwargs) + + # TODO - Pre-fill the serial numbers! + class Meta: model = StockItem diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index e616be1f35..b94036f66e 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -755,7 +755,18 @@ class StockItemSerialize(AjaxUpdateView): model = StockItem ajax_template_name = 'stock/item_serialize.html' ajax_form_title = _('Serialize Stock') - form_class = SerializeStockForm + #form_class = SerializeStockForm + + def get_form(self): + + context = self.get_form_kwargs() + + # Pass the StockItem object through to the form + context['item'] = self.get_object() + + form = SerializeStockForm(**context) + + return form def get_initial(self): From 0a78432a0fbd4a527a2f1cb095601ca56640be49 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 15 May 2020 21:35:53 +1000 Subject: [PATCH 16/52] Convert 'part" to MPTT model - based on the 'variant_of' field - Now recursive variants can be implemented properly --- .../migrations/0039_auto_20200515_1127.py | 50 +++++++++++++++++++ InvenTree/part/models.py | 12 +++-- 2 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 InvenTree/part/migrations/0039_auto_20200515_1127.py diff --git a/InvenTree/part/migrations/0039_auto_20200515_1127.py b/InvenTree/part/migrations/0039_auto_20200515_1127.py new file mode 100644 index 0000000000..bc25097888 --- /dev/null +++ b/InvenTree/part/migrations/0039_auto_20200515_1127.py @@ -0,0 +1,50 @@ +# Generated by Django 3.0.5 on 2020-05-15 11:27 + +from django.db import migrations, models + +from part.models import Part + + +def update_tree(apps, schema_editor): + # Update the MPTT for Part model + Part.objects.rebuild() + + +def nupdate_tree(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0038_auto_20200513_0016'), + ] + + operations = [ + migrations.AddField( + model_name='part', + name='level', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='part', + name='lft', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='part', + name='rght', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='part', + name='tree_id', + field=models.PositiveIntegerField(db_index=True, default=0, editable=False), + preserve_default=False, + ), + + migrations.RunPython(update_tree, reverse_code=nupdate_tree) + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 90383ab26f..c2cfec8d9b 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -24,7 +24,7 @@ from markdownx.models import MarkdownxField from django_cleanup import cleanup -from mptt.models import TreeForeignKey +from mptt.models import TreeForeignKey, MPTTModel from stdimage.models import StdImageField @@ -200,7 +200,7 @@ def match_part_names(match, threshold=80, reverse=True, compare_length=False): @cleanup.ignore -class Part(models.Model): +class Part(MPTTModel): """ The Part object represents an abstract part, the 'concept' of an actual entity. An actual physical instance of a Part is a StockItem which is treated separately. @@ -236,8 +236,12 @@ class Part(models.Model): """ class Meta: - verbose_name = "Part" - verbose_name_plural = "Parts" + verbose_name = _("Part") + verbose_name_plural = _("Parts") + + class MPTTMeta: + # For legacy reasons the 'variant_of' field is used to indicate the MPTT parent + parent_attr='variant_of' def save(self, *args, **kwargs): """ From 0652579312618d8b3461990abf94c34ff9e81a9c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 15 May 2020 21:44:25 +1000 Subject: [PATCH 17/52] Update fixture for part model to match MPTT requirements --- InvenTree/part/fixtures/part.yaml | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index 117763be84..e018c52c3b 100644 --- a/InvenTree/part/fixtures/part.yaml +++ b/InvenTree/part/fixtures/part.yaml @@ -7,6 +7,10 @@ description: 'M2x4 low profile head screw' category: 8 link: www.acme.com/parts/m2x4lphs + tree_id: 0 + level: 0 + lft: 0 + rght: 0 - model: part.part pk: 2 @@ -14,6 +18,10 @@ name: 'M3x12 SHCS' description: 'M3x12 socket head cap screw' category: 8 + tree_id: 0 + level: 0 + lft: 0 + rght: 0 # Create some resistors @@ -23,6 +31,11 @@ name: 'R_2K2_0805' description: '2.2kOhm resistor in 0805 package' category: 2 + tree_id: 0 + level: 0 + lft: 0 + rght: 0 + - model: part.part fields: @@ -30,6 +43,10 @@ description: '4.7kOhm resistor in 0603 package' category: 2 default_location: 2 # Home/Bathroom + tree_id: 0 + level: 0 + lft: 0 + rght: 0 # Create some capacitors - model: part.part @@ -37,6 +54,10 @@ name: 'C_22N_0805' description: '22nF capacitor in 0805 package' category: 3 + tree_id: 0 + level: 0 + lft: 0 + rght: 0 - model: part.part pk: 25 @@ -45,6 +66,10 @@ description: 'A watchamacallit' category: 7 trackable: true + tree_id: 0 + level: 0 + lft: 0 + rght: 0 - model: part.part pk: 50 @@ -52,6 +77,10 @@ name: 'Orphan' description: 'A part without a category' category: null + tree_id: 0 + level: 0 + lft: 0 + rght: 0 # A part that can be made from other parts - model: part.part @@ -64,4 +93,8 @@ category: 7 active: False IPN: BOB - revision: A2 \ No newline at end of file + revision: A2 + tree_id: 0 + level: 0 + lft: 0 + rght: 0 \ No newline at end of file From 2d6c531fda2fcc44b9ca5febe9d7c6e634a8195e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 15 May 2020 22:01:21 +1000 Subject: [PATCH 18/52] Unit testing for part variant MPTT --- InvenTree/part/fixtures/part.yaml | 59 ++++++++++++++++++++++++++++++- InvenTree/part/test_part.py | 12 +++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index e018c52c3b..035049fe81 100644 --- a/InvenTree/part/fixtures/part.yaml +++ b/InvenTree/part/fixtures/part.yaml @@ -97,4 +97,61 @@ tree_id: 0 level: 0 lft: 0 - rght: 0 \ No newline at end of file + rght: 0 + +# A 'template' part +- model: part.part + pk: 10000 + fields: + name: 'Chair Template' + description: 'A chair' + is_template: True + category: 7 + tree_id: 1 + level: 0 + lft: 0 + rght: 0 + +- model: part.part + pk: 10001 + fields: + name: 'Blue Chair' + variant_of: 10000 + category: 7 + tree_id: 1 + level: 0 + lft: 0 + rght: 0 + +- model: part.part + pk: 10002 + fields: + name: 'Red chair' + variant_of: 10000 + category: 7 + tree_id: 1 + level: 0 + lft: 0 + rght: 0 + +- model: part.part + pk: 10003 + fields: + name: 'Green chair' + variant_of: 10000 + category: 7 + tree_id: 1 + level: 0 + lft: 0 + rght: 0 + +- model: part.part + pk: 10004 + fields: + name: 'Green chair variant' + variant_of: 10003 + category: + tree_id: 1 + level: 0 + lft: 0 + rght: 0 diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index c7c3c014a1..0ecb9b5997 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -52,6 +52,18 @@ class PartTest(TestCase): self.C1 = Part.objects.get(name='C_22N_0805') + Part.objects.rebuild() + + def test_tree(self): + # Test that the part variant tree is working properly + chair = Part.objects.get(pk=10000) + self.assertEqual(chair.get_children().count(), 3) + self.assertEqual(chair.get_descendant_count(), 4) + + green = Part.objects.get(pk=10004) + self.assertEqual(green.get_ancestors().count(), 2) + self.assertEqual(green.get_root(), chair) + def test_str(self): p = Part.objects.get(pk=100) self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?") From ea88a03b5ac455e9022ece2024c85fa254c954aa Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 08:43:57 +1000 Subject: [PATCH 19/52] More serial number validation and unit testing - --- InvenTree/part/models.py | 82 ++++++++++++--- InvenTree/part/test_api.py | 2 +- InvenTree/part/test_category.py | 6 +- InvenTree/part/test_part.py | 2 + InvenTree/stock/fixtures/stock.yaml | 156 ++++++++++++++++++++++++++++ InvenTree/stock/models.py | 79 +++++++------- InvenTree/stock/tests.py | 84 ++++++++++++++- 7 files changed, 346 insertions(+), 65 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index c2cfec8d9b..528d932cf0 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -266,6 +266,48 @@ class Part(MPTTModel): def __str__(self): return "{n} - {d}".format(n=self.full_name, d=self.description) + def check_if_serial_number_exists(self, sn): + """ + Check if a serial number exists for this Part. + + Note: Serial numbers must be unique across an entire Part "tree", + so here we filter by the entire tree. + """ + + parts = Part.objects.filter(tree_id=self.tree_id) + stock = StockModels.StockItem.objects.filter(part__in=parts, serial=sn) + + return stock.exists() + + def get_highest_serial_number(self): + """ + Return the highest serial number for this Part. + + Note: Serial numbers must be unique across an entire Part "tree", + so we filter by the entire tree. + """ + + parts = Part.objects.filter(tree_id=self.tree_id) + stock = StockModels.StockItem.objects.filter(part__in=parts).exclude(serial=None).order_by('-serial') + + if stock.count() > 0: + return stock.first().serial + + # No serial numbers found + return None + + def get_next_serial_number(self): + """ + Return the next-available serial number for this Part. + """ + + n = self.get_highest_serial_number() + + if n is None: + return 1 + else: + return n + 1 + @property def full_name(self): """ Format a 'full name' for this Part. @@ -642,32 +684,40 @@ class Part(MPTTModel): self.sales_order_allocation_count(), ]) - @property - def stock_entries(self): - """ Return all 'in stock' items. To be in stock: + def stock_entries(self, include_variants=True, in_stock=None): + """ Return all stock entries for this Part. - - build_order is None - - sales_order is None - - belongs_to is None + - If this is a template part, include variants underneath this. + + Note: To return all stock-entries for all part variants under this one, + we need to be creative with the filtering. """ - return self.stock_items.filter(StockModels.StockItem.IN_STOCK_FILTER) + if include_variants: + query = StockModels.StockItem.objects.filter(part__in=self.get_descendants(include_self=True)) + else: + query = self.stock_items + + if in_stock is True: + query = query.filter(StockModels.StockItem.IN_STOCK_FILTER) + elif in_stock is False: + query = query.exclude(StockModels.StockItem.IN_STOCK_FILTER) + + return query @property def total_stock(self): """ Return the total stock quantity for this part. - Part may be stored in multiple locations + + - Part may be stored in multiple locations + - If this part is a "template" (variants exist) then these are counted too """ - if self.is_template: - total = sum([variant.total_stock for variant in self.variants.all()]) - else: - total = self.stock_entries.filter(status__in=StockStatus.AVAILABLE_CODES).aggregate(total=Sum('quantity'))['total'] + entries = self.stock_entries(in_stock=True) - if total: - return total - else: - return Decimal(0) + query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0))) + + return query['t'] @property def has_bom(self): diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index ca3c747b12..61e7f4ab32 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -85,7 +85,7 @@ class PartAPITest(APITestCase): data = {'cascade': True} response = self.client.get(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 8) + self.assertEqual(len(response.data), 13) def test_get_parts_by_cat(self): url = reverse('api-part-list') diff --git a/InvenTree/part/test_category.py b/InvenTree/part/test_category.py index abd5669016..99d4bce796 100644 --- a/InvenTree/part/test_category.py +++ b/InvenTree/part/test_category.py @@ -88,9 +88,9 @@ class CategoryTest(TestCase): self.assertEqual(self.electronics.partcount(), 3) - self.assertEqual(self.mechanical.partcount(), 4) - self.assertEqual(self.mechanical.partcount(active=True), 3) - self.assertEqual(self.mechanical.partcount(False), 2) + self.assertEqual(self.mechanical.partcount(), 8) + self.assertEqual(self.mechanical.partcount(active=True), 7) + self.assertEqual(self.mechanical.partcount(False), 6) self.assertEqual(self.electronics.item_count, self.electronics.partcount()) diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 0ecb9b5997..622f0af547 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -63,6 +63,8 @@ class PartTest(TestCase): green = Part.objects.get(pk=10004) self.assertEqual(green.get_ancestors().count(), 2) self.assertEqual(green.get_root(), chair) + self.assertEqual(green.get_family().count(), 3) + self.assertEqual(Part.objects.filter(tree_id=chair.tree_id).count(), 5) def test_str(self): p = Part.objects.get(pk=100) diff --git a/InvenTree/stock/fixtures/stock.yaml b/InvenTree/stock/fixtures/stock.yaml index ebc207f29c..cac34fb01b 100644 --- a/InvenTree/stock/fixtures/stock.yaml +++ b/InvenTree/stock/fixtures/stock.yaml @@ -68,4 +68,160 @@ level: 0 tree_id: 0 lft: 0 + rght: 0 + +# Stock items for template / variant parts +- model: stock.stockitem + pk: 500 + fields: + part: 10001 + location: 7 + quantity: 5 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 501 + fields: + part: 10001 + location: 7 + quantity: 1 + serial: 1 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 501 + fields: + part: 10001 + location: 7 + quantity: 1 + serial: 1 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 502 + fields: + part: 10001 + location: 7 + quantity: 1 + serial: 2 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 503 + fields: + part: 10001 + location: 7 + quantity: 1 + serial: 3 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 504 + fields: + part: 10001 + location: 7 + quantity: 1 + serial: 4 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 505 + fields: + part: 10001 + location: 7 + quantity: 1 + serial: 5 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 510 + fields: + part: 10002 + location: 7 + quantity: 1 + serial: 10 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 511 + fields: + part: 10002 + location: 7 + quantity: 1 + serial: 11 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 512 + fields: + part: 10002 + location: 7 + quantity: 1 + serial: 12 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 520 + fields: + part: 10004 + location: 7 + quantity: 1 + serial: 20 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 521 + fields: + part: 10004 + location: 7 + quantity: 1 + serial: 21 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + +- model: stock.stockitem + pk: 522 + fields: + part: 10004 + location: 7 + quantity: 1 + serial: 22 + level: 0 + tree_id: 0 + lft: 0 rght: 0 \ No newline at end of file diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index ecfbf751c9..e7e4223f24 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -142,11 +142,31 @@ class StockItem(MPTTModel): ) def save(self, *args, **kwargs): + """ + Save this StockItem to the database. Performs a number of checks: + + - Unique serial number requirement + - Adds a transaction note when the item is first created. + """ + + # Query to look for duplicate serial numbers + parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id) + stock = StockItem.objects.filter(part__in=parts, serial=self.serial) + if not self.pk: + # StockItem has not yet been saved add_note = True else: + # StockItem has already been saved add_note = False + stock = stock.exclude(pk=self.pk) + + if self.serial is not None: + # Check for presence of stock with same serial number + if stock.exists(): + raise ValidationError({"serial": _("StockItem with this serial number already exists")}) + user = kwargs.pop('user', None) add_note = add_note and kwargs.pop('note', True) @@ -172,37 +192,6 @@ class StockItem(MPTTModel): """ Return True if this StockItem is serialized """ return self.serial is not None and self.quantity == 1 - @classmethod - def check_serial_number(cls, part, serial_number): - """ Check if a new stock item can be created with the provided part_id - - Args: - part: The part to be checked - """ - - if not part.trackable: - return False - - # Return False if an invalid serial number is supplied - try: - serial_number = int(serial_number) - except ValueError: - return False - - items = StockItem.objects.filter(serial=serial_number) - - # Is this part a variant? If so, check S/N across all sibling variants - if part.variant_of is not None: - items = items.filter(part__variant_of=part.variant_of) - else: - items = items.filter(part=part) - - # An existing serial number exists - if items.exists(): - return False - - return True - def validate_unique(self, exclude=None): super(StockItem, self).validate_unique(exclude) @@ -210,18 +199,21 @@ class StockItem(MPTTModel): # ensure that the serial number is unique # across all variants of the same template part + print("validating...") + print(self.pk, self.serial) + try: if self.serial is not None: - # This is a variant part (check S/N across all sibling variants) - if self.part.variant_of is not None: - if StockItem.objects.filter(part__variant_of=self.part.variant_of, serial=self.serial).exclude(id=self.id).exists(): - raise ValidationError({ - 'serial': _('A stock item with this serial number already exists for template part {part}'.format(part=self.part.variant_of)) - }) - else: - if StockItem.objects.filter(part=self.part, serial=self.serial).exclude(id=self.id).exists(): - raise ValidationError({ - 'serial': _('A stock item with this serial number already exists') + + parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id) + stock = StockItem.objects.filter( + part__in=parts, + serial=self.serial, + ).exclude(pk=self.pk) + + if stock.exists(): + raise ValidationError({ + 'serial': _('A stock item with this serial number already exists for this part'), }) except PartModels.Part.DoesNotExist: pass @@ -599,6 +591,9 @@ class StockItem(MPTTModel): if self.serialized: return + if not self.part.trackable: + raise ValidationError({"part": _("Part is not set as trackable")}) + # Quantity must be a valid integer value try: quantity = int(quantity) @@ -624,7 +619,7 @@ class StockItem(MPTTModel): existing = [] for serial in serials: - if not StockItem.check_serial_number(self.part, serial): + if self.part.check_if_serial_number_exists(serial): existing.append(serial) if len(existing) > 0: diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index a866bdb880..307879629a 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -38,6 +38,10 @@ class StockTest(TestCase): self.user = User.objects.get(username='username') + # Ensure the MPTT objects are correctly rebuild + Part.objects.rebuild() + StockItem.objects.rebuild() + def test_loc_count(self): self.assertEqual(StockLocation.objects.count(), 7) @@ -91,13 +95,16 @@ class StockTest(TestCase): self.assertFalse(self.drawer2.has_items()) # Drawer 3 should have three stock items - self.assertEqual(self.drawer3.stock_items.count(), 3) - self.assertEqual(self.drawer3.item_count, 3) + self.assertEqual(self.drawer3.stock_items.count(), 15) + self.assertEqual(self.drawer3.item_count, 15) def test_stock_count(self): part = Part.objects.get(pk=1) + entries = part.stock_entries() - # There should be 5000 screws in stock + self.assertEqual(entries.count(), 2) + + # There should be 9000 screws in stock self.assertEqual(part.total_stock, 9000) # There should be 18 widgets in stock @@ -301,6 +308,7 @@ class StockTest(TestCase): item.delete_on_deplete = True item.save() + n = StockItem.objects.filter(part=25).count() self.assertEqual(item.quantity, 10) @@ -327,3 +335,73 @@ class StockTest(TestCase): # Serialize the remainder of the stock item.serializeStock(2, [99, 100], self.user) + + +class VariantTest(StockTest): + """ + Tests for calculation stock counts against templates / variants + """ + + def test_variant_stock(self): + # Check the 'Chair' variant + chair = Part.objects.get(pk=10000) + + # No stock items for the variant part itself + self.assertEqual(chair.stock_entries(include_variants=False).count(), 0) + + self.assertEqual(chair.stock_entries().count(), 12) + + green = Part.objects.get(pk=10003) + self.assertEqual(green.stock_entries(include_variants=False).count(), 0) + self.assertEqual(green.stock_entries().count(), 3) + + def test_serial_numbers(self): + # Test serial number functionality for variant / template parts + + chair = Part.objects.get(pk=10000) + + # Operations on the top-level object + self.assertTrue(chair.check_if_serial_number_exists(1)) + self.assertTrue(chair.check_if_serial_number_exists(2)) + self.assertTrue(chair.check_if_serial_number_exists(3)) + self.assertTrue(chair.check_if_serial_number_exists(4)) + self.assertTrue(chair.check_if_serial_number_exists(5)) + + self.assertTrue(chair.check_if_serial_number_exists(20)) + self.assertTrue(chair.check_if_serial_number_exists(21)) + self.assertTrue(chair.check_if_serial_number_exists(22)) + + self.assertFalse(chair.check_if_serial_number_exists(30)) + + self.assertEqual(chair.get_next_serial_number(), 23) + + # Same operations on a sub-item + variant = Part.objects.get(pk=10003) + self.assertEqual(variant.get_next_serial_number(), 23) + + # Create a new serial number + n = variant.get_highest_serial_number() + + item = StockItem( + part=variant, + quantity=1, + serial=n + ) + + # This should fail + with self.assertRaises(ValidationError): + item.save() + + # This should pass + item.serial = n + 1 + item.save() + + # Attempt to create the same serial number but for a variant (should fail!) + item.pk = None + item.part = Part.objects.get(pk=10004) + + with self.assertRaises(ValidationError): + item.save() + + item.serial += 1 + item.save() From 10762fc1cf085ec969f0f4702fc7e814ee0facef Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 08:55:19 +1000 Subject: [PATCH 20/52] Refactor tractor --- InvenTree/part/models.py | 4 ++-- InvenTree/stock/models.py | 47 ++++++++++++++------------------------- 2 files changed, 19 insertions(+), 32 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 528d932cf0..0fdf764961 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -39,7 +39,7 @@ from InvenTree.models import InvenTreeTree, InvenTreeAttachment from InvenTree.fields import InvenTreeURLField from InvenTree.helpers import decimal2string, normalize -from InvenTree.status_codes import BuildStatus, StockStatus, PurchaseOrderStatus +from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus from build import models as BuildModels from order import models as OrderModels @@ -241,7 +241,7 @@ class Part(MPTTModel): class MPTTMeta: # For legacy reasons the 'variant_of' field is used to indicate the MPTT parent - parent_attr='variant_of' + parent_attr = 'variant_of' def save(self, *args, **kwargs): """ diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index e7e4223f24..bf9c763ae7 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -149,9 +149,8 @@ class StockItem(MPTTModel): - Adds a transaction note when the item is first created. """ - # Query to look for duplicate serial numbers - parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id) - stock = StockItem.objects.filter(part__in=parts, serial=self.serial) + self.validate_unique() + self.clean() if not self.pk: # StockItem has not yet been saved @@ -160,13 +159,6 @@ class StockItem(MPTTModel): # StockItem has already been saved add_note = False - stock = stock.exclude(pk=self.pk) - - if self.serial is not None: - # Check for presence of stock with same serial number - if stock.exists(): - raise ValidationError({"serial": _("StockItem with this serial number already exists")}) - user = kwargs.pop('user', None) add_note = add_note and kwargs.pop('note', True) @@ -193,30 +185,25 @@ class StockItem(MPTTModel): return self.serial is not None and self.quantity == 1 def validate_unique(self, exclude=None): + """ + Test that this StockItem is "unique". + If the StockItem is serialized, the same serial number. + cannot exist for the same part (or part tree). + """ + super(StockItem, self).validate_unique(exclude) - # If the Part object is a variant (of a template part), - # ensure that the serial number is unique - # across all variants of the same template part + if self.serial is not None: + # Query to look for duplicate serial numbers + parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id) + stock = StockItem.objects.filter(part__in=parts, serial=self.serial) - print("validating...") - print(self.pk, self.serial) + # Exclude myself from the search + if self.pk is not None: + stock = stock.exclude(pk=self.pk) - try: - if self.serial is not None: - - parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id) - stock = StockItem.objects.filter( - part__in=parts, - serial=self.serial, - ).exclude(pk=self.pk) - - if stock.exists(): - raise ValidationError({ - 'serial': _('A stock item with this serial number already exists for this part'), - }) - except PartModels.Part.DoesNotExist: - pass + if stock.exists(): + raise ValidationError({"serial": _("StockItem with this serial number already exists")}) def clean(self): """ Validate the StockItem object (separate to field validation) From 0ccac09962b5adad621aaba21b9812ac9afd5201 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 09:06:39 +1000 Subject: [PATCH 21/52] Auto-fill serial numbers for the SerializeStock form --- InvenTree/stock/forms.py | 25 +++++++++++++++++++++++-- InvenTree/stock/views.py | 9 +++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 0286054766..3eb3c1018b 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -9,6 +9,9 @@ from django import forms from django.forms.utils import ErrorDict from django.utils.translation import ugettext as _ +from crispy_forms.layout import Field, Layout +from crispy_forms.bootstrap import PrependedText + from mptt.fields import TreeNodeChoiceField from InvenTree.helpers import GetExportFormats @@ -95,11 +98,29 @@ class SerializeStockForm(HelperForm): def __init__(self, *args, **kwargs): # Extract the stock item - stock_item = kwargs.pop('item') + item = kwargs.pop('item') super().__init__(*args, **kwargs) - # TODO - Pre-fill the serial numbers! + # Pre-calculate what the serial numbers should be! + sn = item.part.get_next_serial_number() + + if item.quantity >= 2: + sn = "{n}-{m}".format(n=sn, m=int(sn+item.quantity-1)) + else: + sn = str(sn) + + # TODO - Refactor this? Should not have to specify Field('field') for each field... + self.helper.layout = Layout( + Field('quantity'), + Field(PrependedText( + 'serial_numbers', + '#', + placeholder=sn + )), + Field('destination'), + Field('note'), + ) class Meta: model = StockItem diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index b94036f66e..13ec701c10 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -774,7 +774,16 @@ class StockItemSerialize(AjaxUpdateView): item = self.get_object() + # Pre-calculate what the serial numbers should be! + sn = item.part.get_next_serial_number() + + if item.quantity >= 2: + sn = "{n}-{m}".format(n=sn, m=int(sn+item.quantity-1)) + else: + sn = str(sn) + initials['quantity'] = item.quantity + initials['serial_numbers'] = sn initials['destination'] = item.location.pk return initials From 8fae32e3c712aca1082b784e8b36bcc16aa37264 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 09:33:34 +1000 Subject: [PATCH 22/52] Refactor HelperForm to easily allow setting prepended text / placeholder / etc --- InvenTree/InvenTree/forms.py | 39 ++++++++++++++++++++++++++++++++++-- InvenTree/stock/forms.py | 22 +++++++++----------- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index 3d8871f824..fb47f2a32b 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -7,13 +7,19 @@ from __future__ import unicode_literals from django import forms from crispy_forms.helper import FormHelper -from crispy_forms.layout import Layout +from crispy_forms.layout import Layout, Field +from crispy_forms.bootstrap import PrependedAppendedText from django.contrib.auth.models import User class HelperForm(forms.ModelForm): """ Provides simple integration of crispy_forms extension. """ + # Custom field decorations can be specified here, per form class + prefix = {} + suffix = {} + placeholder = {} + def __init__(self, *args, **kwargs): super(forms.ModelForm, self).__init__(*args, **kwargs) self.helper = FormHelper() @@ -28,7 +34,36 @@ class HelperForm(forms.ModelForm): Simply create a 'blank' layout for each available field. """ - self.helper.layout = Layout(*self.fields.keys()) + layouts = [] + + for field in self.fields: + prefix = self.prefix.get(field, None) + suffix = self.suffix.get(field, None) + placeholder = self.placeholder.get(field, None) + + # Look for font-awesome icons + if prefix and prefix.startswith('fa-'): + prefix = "".format(fa=prefix) + + if suffix and suffix.startswith('fa-'): + suffix = "".format(fa=suffix) + + if prefix or suffix or placeholder: + layouts.append( + Field( + PrependedAppendedText( + field, + prepended_text=prefix, + appended_text=suffix, + placeholder=placeholder + ) + ) + ) + + else: + layouts.append(Field(field)) + + self.helper.layout = Layout(*layouts) class DeleteForm(forms.Form): diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 3eb3c1018b..cf04396f54 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -100,8 +100,6 @@ class SerializeStockForm(HelperForm): # Extract the stock item item = kwargs.pop('item') - super().__init__(*args, **kwargs) - # Pre-calculate what the serial numbers should be! sn = item.part.get_next_serial_number() @@ -110,17 +108,15 @@ class SerializeStockForm(HelperForm): else: sn = str(sn) - # TODO - Refactor this? Should not have to specify Field('field') for each field... - self.helper.layout = Layout( - Field('quantity'), - Field(PrependedText( - 'serial_numbers', - '#', - placeholder=sn - )), - Field('destination'), - Field('note'), - ) + self.prefix = { + 'serial_numbers': 'fa-hashtag', + } + + self.placeholder = { + 'serial_numbers': sn + } + + super().__init__(*args, **kwargs) class Meta: model = StockItem From 08d177e55fc60d7b5287be7c04729cf2b013750d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 09:36:43 +1000 Subject: [PATCH 23/52] Update refactor for editing PO and SO forms --- InvenTree/InvenTree/forms.py | 2 +- InvenTree/order/forms.py | 44 +++++++++++++++--------------------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index fb47f2a32b..0fb625fb29 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -39,7 +39,7 @@ class HelperForm(forms.ModelForm): for field in self.fields: prefix = self.prefix.get(field, None) suffix = self.suffix.get(field, None) - placeholder = self.placeholder.get(field, None) + placeholder = self.placeholder.get(field, '') # Look for font-awesome icons if prefix and prefix.startswith('fa-'): diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index e7e26064f8..8496fee501 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -93,20 +93,16 @@ class EditPurchaseOrderForm(HelperForm): def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + self.prefix = { + 'reference': 'PO', + 'link': 'fa-link', + } - # TODO - Refactor this? - self.helper.layout = Layout( - Field(PrependedText( - 'reference', - 'PO', - placeholder=_("Purchase Order") - )), - Field('supplier'), - Field('supplier_reference'), - Field('description'), - Field('link'), - ) + self.placeholder = { + 'reference': _('Enter purchase order number'), + } + + super().__init__(*args, **kwargs) class Meta: model = PurchaseOrder @@ -124,20 +120,16 @@ class EditSalesOrderForm(HelperForm): def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + self.prefix = { + 'reference': 'SO', + 'link': 'fa-link', + } - # TODO - Refactor? - self.helper.layout = Layout( - Field(PrependedText( - 'reference', - 'SO', - placeholder=_("Sales Order") - )), - Field('customer'), - Field('customer_reference'), - Field('description'), - Field('link'), - ) + self.placeholder = { + 'reference': _('Enter sales order number'), + } + + super().__init__(*args, **kwargs) class Meta: model = SalesOrder From 498ad4162c08c42810933ba633f626c5f6165161 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 11:05:45 +1000 Subject: [PATCH 24/52] Bugfix: Turns out 'prefix' and 'suffix' were protected fields! --- InvenTree/InvenTree/forms.py | 44 +++++++++++++++++++++++++++--------- InvenTree/order/forms.py | 8 +++---- InvenTree/stock/forms.py | 4 ++-- InvenTree/stock/views.py | 2 +- 4 files changed, 40 insertions(+), 18 deletions(-) diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index 0fb625fb29..b4509446f6 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -8,7 +8,7 @@ from __future__ import unicode_literals from django import forms from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Field -from crispy_forms.bootstrap import PrependedAppendedText +from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppendedText from django.contrib.auth.models import User @@ -16,9 +16,9 @@ class HelperForm(forms.ModelForm): """ Provides simple integration of crispy_forms extension. """ # Custom field decorations can be specified here, per form class - prefix = {} - suffix = {} - placeholder = {} + field_prefix = {} + field_suffix = {} + field_placeholder = {} def __init__(self, *args, **kwargs): super(forms.ModelForm, self).__init__(*args, **kwargs) @@ -37,18 +37,18 @@ class HelperForm(forms.ModelForm): layouts = [] for field in self.fields: - prefix = self.prefix.get(field, None) - suffix = self.suffix.get(field, None) - placeholder = self.placeholder.get(field, '') + prefix = self.field_prefix.get(field, None) + suffix = self.field_suffix.get(field, None) + placeholder = self.field_placeholder.get(field, '') # Look for font-awesome icons if prefix and prefix.startswith('fa-'): - prefix = "".format(fa=prefix) + prefix = r"".format(fa=prefix) if suffix and suffix.startswith('fa-'): - suffix = "".format(fa=suffix) + suffix = r"".format(fa=suffix) - if prefix or suffix or placeholder: + if prefix and suffix: layouts.append( Field( PrependedAppendedText( @@ -60,8 +60,30 @@ class HelperForm(forms.ModelForm): ) ) + elif prefix: + layouts.append( + Field( + PrependedText( + field, + prefix, + placeholder=placeholder + ) + ) + ) + + elif suffix: + layouts.append( + Field( + AppendedText( + field, + suffix, + placeholder=placeholder + ) + ) + ) + else: - layouts.append(Field(field)) + layouts.append(Field(field, placeholder=placeholder)) self.helper.layout = Layout(*layouts) diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 8496fee501..2887ef8892 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -93,12 +93,12 @@ class EditPurchaseOrderForm(HelperForm): def __init__(self, *args, **kwargs): - self.prefix = { + self.field_prefix = { 'reference': 'PO', 'link': 'fa-link', } - self.placeholder = { + self.field_placeholder = { 'reference': _('Enter purchase order number'), } @@ -120,12 +120,12 @@ class EditSalesOrderForm(HelperForm): def __init__(self, *args, **kwargs): - self.prefix = { + self.field_prefix = { 'reference': 'SO', 'link': 'fa-link', } - self.placeholder = { + self.field_placeholder = { 'reference': _('Enter sales order number'), } diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index cf04396f54..22c302c859 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -108,11 +108,11 @@ class SerializeStockForm(HelperForm): else: sn = str(sn) - self.prefix = { + self.field_prefix = { 'serial_numbers': 'fa-hashtag', } - self.placeholder = { + self.field_placeholder = { 'serial_numbers': sn } diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 13ec701c10..c33af4e15a 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -717,7 +717,7 @@ class StockItemEdit(AjaxUpdateView): query = query.filter(part=item.part.id) form.fields['supplier_part'].queryset = query - if not item.part.trackable: + if not item.part.trackable or not item.serialized: form.fields.pop('serial') return form From 4cb97b1340e4361744432a706c0259c598b8fbd2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 11:55:10 +1000 Subject: [PATCH 25/52] Add some more form candy --- InvenTree/company/forms.py | 15 +++++++++++ InvenTree/part/forms.py | 10 ++++++++ InvenTree/stock/forms.py | 35 ++++++++++++++++++-------- InvenTree/stock/views.py | 1 + InvenTree/templates/table_filters.html | 4 +++ 5 files changed, 54 insertions(+), 11 deletions(-) diff --git a/InvenTree/company/forms.py b/InvenTree/company/forms.py index 003cfaf495..ac3cc69c99 100644 --- a/InvenTree/company/forms.py +++ b/InvenTree/company/forms.py @@ -16,6 +16,14 @@ from .models import SupplierPriceBreak class EditCompanyForm(HelperForm): """ Form for editing a Company object """ + field_prefix = { + 'website': 'fa-globe-asia', + 'email': 'fa-at', + 'address': 'fa-envelope', + 'contact': 'fa-user-tie', + 'phone': 'fa-phone', + } + class Meta: model = Company fields = [ @@ -45,6 +53,13 @@ class CompanyImageForm(HelperForm): class EditSupplierPartForm(HelperForm): """ Form for editing a SupplierPart object """ + field_prefix = { + 'link': 'fa-link', + 'MPN': 'fa-hashtag', + 'SKU': 'fa-hashtag', + 'note': 'fa-pencil-alt', + } + class Meta: model = SupplierPart fields = [ diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 332e9b6611..4d70ab927a 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -97,6 +97,12 @@ class SetPartCategoryForm(forms.Form): class EditPartForm(HelperForm): """ Form for editing a Part object """ + field_prefix = { + 'keywords': 'fa-key', + 'link': 'fa-link', + 'IPN': 'fa-hashtag', + } + deep_copy = forms.BooleanField(required=False, initial=True, help_text=_("Perform 'deep copy' which will duplicate all BOM data for this part"), @@ -155,6 +161,10 @@ class EditPartParameterForm(HelperForm): class EditCategoryForm(HelperForm): """ Form for editing a PartCategory object """ + field_prefix = { + 'default_keywords': 'fa-key', + } + class Meta: model = PartCategory fields = [ diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 22c302c859..4eaff44b74 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -52,6 +52,15 @@ class CreateStockItemForm(HelperForm): serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text=_('Enter unique serial numbers (or leave blank)')) + def __init__(self, *args, **kwargs): + + self.field_prefix = { + 'serial_numbers': 'fa-hashtag', + 'link': 'fa-link', + } + + super().__init__(*args, **kwargs) + class Meta: model = StockItem fields = [ @@ -74,6 +83,7 @@ class CreateStockItemForm(HelperForm): return self.cleaned_data = {} + # If the form is permitted to be empty, and none of the form data has # changed from the initial data, short circuit any validation. if self.empty_permitted and not self.has_changed(): @@ -98,24 +108,27 @@ class SerializeStockForm(HelperForm): def __init__(self, *args, **kwargs): # Extract the stock item - item = kwargs.pop('item') + item = kwargs.pop('item', None) - # Pre-calculate what the serial numbers should be! - sn = item.part.get_next_serial_number() + if item: - if item.quantity >= 2: - sn = "{n}-{m}".format(n=sn, m=int(sn+item.quantity-1)) - else: - sn = str(sn) + # Pre-calculate what the serial numbers should be! + sn = item.part.get_next_serial_number() + + if item.quantity >= 2: + sn = "{n}-{m}".format(n=sn, m=int(sn+item.quantity-1)) + else: + sn = str(sn) + + + self.field_placeholder = { + 'serial_numbers': sn + } self.field_prefix = { 'serial_numbers': 'fa-hashtag', } - self.field_placeholder = { - 'serial_numbers': sn - } - super().__init__(*args, **kwargs) class Meta: diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index c33af4e15a..a7195a5300 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -937,6 +937,7 @@ class StockItemCreate(AjaxCreateView): if part_id: try: part = Part.objects.get(pk=part_id) + # Check that the supplied part is 'valid' if not part.is_template and part.active and not part.virtual: initials['part'] = part diff --git a/InvenTree/templates/table_filters.html b/InvenTree/templates/table_filters.html index b337b25ac8..de0b049d57 100644 --- a/InvenTree/templates/table_filters.html +++ b/InvenTree/templates/table_filters.html @@ -110,6 +110,10 @@ function getAvailableTableFilters(tableKey) { type: 'bool', title: '{% trans "Salable" %}', }, + trackable: { + type: 'bool', + title: '{% trans "Trackable" %}', + }, purchaseable: { type: 'bool', title: '{% trans "Purchasable" %}', From 3d0bea15ae12a3c0a40091429c9924b8d49f0497 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 12:03:18 +1000 Subject: [PATCH 26/52] Refactor function naming --- InvenTree/build/views.py | 2 +- InvenTree/part/models.py | 8 ++++---- InvenTree/stock/forms.py | 2 +- InvenTree/stock/models.py | 2 +- InvenTree/stock/tests.py | 24 ++++++++++++------------ InvenTree/stock/views.py | 2 +- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 6f651a7628..96eb602f2b 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -282,7 +282,7 @@ class BuildComplete(AjaxUpdateView): existing = [] for serial in serials: - if not StockItem.check_serial_number(build.part, serial): + if build.part.checkIfSerialNumberExists(serial): existing.append(serial) if len(existing) > 0: diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 0fdf764961..31771dc291 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -266,7 +266,7 @@ class Part(MPTTModel): def __str__(self): return "{n} - {d}".format(n=self.full_name, d=self.description) - def check_if_serial_number_exists(self, sn): + def checkIfSerialNumberExists(self, sn): """ Check if a serial number exists for this Part. @@ -279,7 +279,7 @@ class Part(MPTTModel): return stock.exists() - def get_highest_serial_number(self): + def getHighestSerialNumber(self): """ Return the highest serial number for this Part. @@ -296,12 +296,12 @@ class Part(MPTTModel): # No serial numbers found return None - def get_next_serial_number(self): + def getNextSerialNumber(self): """ Return the next-available serial number for this Part. """ - n = self.get_highest_serial_number() + n = self.getHighestSerialNumber() if n is None: return 1 diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 4eaff44b74..520ac72835 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -113,7 +113,7 @@ class SerializeStockForm(HelperForm): if item: # Pre-calculate what the serial numbers should be! - sn = item.part.get_next_serial_number() + sn = item.part.getNextSerialNumber() if item.quantity >= 2: sn = "{n}-{m}".format(n=sn, m=int(sn+item.quantity-1)) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index bf9c763ae7..e0f38ba871 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -606,7 +606,7 @@ class StockItem(MPTTModel): existing = [] for serial in serials: - if self.part.check_if_serial_number_exists(serial): + if self.part.checkIfSerialNumberExists(serial): existing.append(serial) if len(existing) > 0: diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index 307879629a..ab038c3c62 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -361,26 +361,26 @@ class VariantTest(StockTest): chair = Part.objects.get(pk=10000) # Operations on the top-level object - self.assertTrue(chair.check_if_serial_number_exists(1)) - self.assertTrue(chair.check_if_serial_number_exists(2)) - self.assertTrue(chair.check_if_serial_number_exists(3)) - self.assertTrue(chair.check_if_serial_number_exists(4)) - self.assertTrue(chair.check_if_serial_number_exists(5)) + self.assertTrue(chair.checkIfSerialNumberExists(1)) + self.assertTrue(chair.checkIfSerialNumberExists(2)) + self.assertTrue(chair.checkIfSerialNumberExists(3)) + self.assertTrue(chair.checkIfSerialNumberExists(4)) + self.assertTrue(chair.checkIfSerialNumberExists(5)) - self.assertTrue(chair.check_if_serial_number_exists(20)) - self.assertTrue(chair.check_if_serial_number_exists(21)) - self.assertTrue(chair.check_if_serial_number_exists(22)) + self.assertTrue(chair.checkIfSerialNumberExists(20)) + self.assertTrue(chair.checkIfSerialNumberExists(21)) + self.assertTrue(chair.checkIfSerialNumberExists(22)) - self.assertFalse(chair.check_if_serial_number_exists(30)) + self.assertFalse(chair.checkIfSerialNumberExists(30)) - self.assertEqual(chair.get_next_serial_number(), 23) + self.assertEqual(chair.getNextSerialNumber(), 23) # Same operations on a sub-item variant = Part.objects.get(pk=10003) - self.assertEqual(variant.get_next_serial_number(), 23) + self.assertEqual(variant.getNextSerialNumber(), 23) # Create a new serial number - n = variant.get_highest_serial_number() + n = variant.getHighestSerialNumber() item = StockItem( part=variant, diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index a7195a5300..ee5367e346 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -775,7 +775,7 @@ class StockItemSerialize(AjaxUpdateView): item = self.get_object() # Pre-calculate what the serial numbers should be! - sn = item.part.get_next_serial_number() + sn = item.part.getNextSerialNumber() if item.quantity >= 2: sn = "{n}-{m}".format(n=sn, m=int(sn+item.quantity-1)) From 3df8f330808db5d21bd039d36dbcc629a5082784 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 12:04:53 +1000 Subject: [PATCH 27/52] Logic fixes for CreateStockItem form - Improved data validation - Fix bug where form was not checked for validity --- InvenTree/stock/models.py | 12 ++++++ InvenTree/stock/views.py | 81 +++++++++++++++++++++++---------------- 2 files changed, 59 insertions(+), 34 deletions(-) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index e0f38ba871..7ba7aa1e26 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -227,6 +227,18 @@ class StockItem(MPTTModel): 'status': 'Status cannot be marked as ASSIGNED_TO_OTHER_ITEM if the belongs_to field is not set', }) + if self.part.trackable: + # Trackable parts must have integer values for quantity field! + if not self.quantity == int(self.quantity): + raise ValidationError({ + 'quantity': _('Quantity must be integer value for trackable parts') + }) + + if self.quantity <= 0: + raise ValidationError({ + 'quantity': _('Quantity must be greater than zero') + }) + # The 'supplier_part' field must point to the same part! try: if self.supplier_part is not None: diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index ee5367e346..7312ee67e6 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -984,19 +984,23 @@ class StockItemCreate(AjaxCreateView): if valid: part_id = form['part'].value() try: - part = Part.objects.get(id=part_id) + self.part = Part.objects.get(id=part_id) quantity = Decimal(form['quantity'].value()) except (Part.DoesNotExist, ValueError, InvalidOperation): - part = None + self.part = None quantity = 1 valid = False form.errors['quantity'] = [_('Invalid quantity')] - if part is None: + if quantity <= 0: + form.errors['quantity'] = [_('Quantity must be greater than zero')] + valid = False + + if self.part is None: form.errors['part'] = [_('Invalid part selection')] else: # A trackable part must provide serial numbesr - if part.trackable: + if self.part.trackable: sn = request.POST.get('serial_numbers', '') sn = str(sn).strip() @@ -1009,7 +1013,7 @@ class StockItemCreate(AjaxCreateView): existing = [] for serial in serials: - if not StockItem.check_serial_number(part, serial): + if self.part.checkIfSerialNumberExists(serial): existing.append(serial) if len(existing) > 0: @@ -1024,24 +1028,26 @@ class StockItemCreate(AjaxCreateView): form_data = form.cleaned_data - for serial in serials: - # Create a new stock item for each serial number - item = StockItem( - part=part, - quantity=1, - serial=serial, - supplier_part=form_data.get('supplier_part'), - location=form_data.get('location'), - batch=form_data.get('batch'), - delete_on_deplete=False, - status=form_data.get('status'), - link=form_data.get('link'), - ) + if form.is_valid(): - item.save(user=request.user) + for serial in serials: + # Create a new stock item for each serial number + item = StockItem( + part=self.part, + quantity=1, + serial=serial, + supplier_part=form_data.get('supplier_part'), + location=form_data.get('location'), + batch=form_data.get('batch'), + delete_on_deplete=False, + status=form_data.get('status'), + link=form_data.get('link'), + ) - data['success'] = _('Created {n} new stock items'.format(n=len(serials))) - valid = True + item.save(user=request.user) + + data['success'] = _('Created {n} new stock items'.format(n=len(serials))) + valid = True except ValidationError as e: form.errors['serial_numbers'] = e.messages @@ -1052,6 +1058,24 @@ class StockItemCreate(AjaxCreateView): form.clean() form._post_clean() + if form.is_valid(): + + item = form.save(commit=False) + item.save(user=request.user) + + data['pk'] = item.pk + data['url'] = item.get_absolute_url() + data['success'] = _("Created new stock item") + + valid = True + + else: # Referenced Part object is not marked as "trackable" + # For non-serialized items, simply save the form. + # We need to call _post_clean() here because it is prevented in the form implementation + form.clean() + form._post_clean() + + if form.is_valid: item = form.save(commit=False) item.save(user=request.user) @@ -1059,20 +1083,9 @@ class StockItemCreate(AjaxCreateView): data['url'] = item.get_absolute_url() data['success'] = _("Created new stock item") - else: # Referenced Part object is not marked as "trackable" - # For non-serialized items, simply save the form. - # We need to call _post_clean() here because it is prevented in the form implementation - form.clean() - form._post_clean() - - item = form.save(commit=False) - item.save(user=request.user) + valid = True - data['pk'] = item.pk - data['url'] = item.get_absolute_url() - data['success'] = _("Created new stock item") - - data['form_valid'] = valid + data['form_valid'] = valid and form.is_valid() return self.renderJsonResponse(request, form, data=data) From 6552d011a4fa2861b89069dc60a22fda475d0025 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 16:42:34 +1000 Subject: [PATCH 28/52] Better calculatation of placeholder text for serial number --- InvenTree/stock/views.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 7312ee67e6..6493a9d381 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -864,6 +864,8 @@ class StockItemCreate(AjaxCreateView): form = super().get_form() + part = None + # If the user has selected a Part, limit choices for SupplierPart if form['part'].value(): part_id = form['part'].value() @@ -871,6 +873,9 @@ class StockItemCreate(AjaxCreateView): try: part = Part.objects.get(id=part_id) + sn = part.getNextSerialNumber() + form.field_placeholder['serial_numbers'] = _('Next available serial number is') + ' ' + str(sn) + # Hide the 'part' field (as a valid part is selected) form.fields['part'].widget = HiddenInput() @@ -893,6 +898,7 @@ class StockItemCreate(AjaxCreateView): # If there is one (and only one) supplier part available, pre-select it all_parts = parts.all() + if len(all_parts) == 1: # TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate @@ -904,7 +910,7 @@ class StockItemCreate(AjaxCreateView): # Otherwise if the user has selected a SupplierPart, we know what Part they meant! elif form['supplier_part'].value() is not None: pass - + return form def get_initial(self): @@ -975,6 +981,8 @@ class StockItemCreate(AjaxCreateView): - Manage serial-number valdiation for tracked parts """ + part = None + form = self.get_form() data = {} @@ -984,10 +992,14 @@ class StockItemCreate(AjaxCreateView): if valid: part_id = form['part'].value() try: - self.part = Part.objects.get(id=part_id) + part = Part.objects.get(id=part_id) quantity = Decimal(form['quantity'].value()) + + sn = part.getNextSerialNumber() + form.field_placeholder['serial_numbers'] = _("Next available serial number is") + " " + str(sn) + except (Part.DoesNotExist, ValueError, InvalidOperation): - self.part = None + part = None quantity = 1 valid = False form.errors['quantity'] = [_('Invalid quantity')] @@ -996,11 +1008,11 @@ class StockItemCreate(AjaxCreateView): form.errors['quantity'] = [_('Quantity must be greater than zero')] valid = False - if self.part is None: + if part is None: form.errors['part'] = [_('Invalid part selection')] else: # A trackable part must provide serial numbesr - if self.part.trackable: + if part.trackable: sn = request.POST.get('serial_numbers', '') sn = str(sn).strip() @@ -1013,7 +1025,7 @@ class StockItemCreate(AjaxCreateView): existing = [] for serial in serials: - if self.part.checkIfSerialNumberExists(serial): + if part.checkIfSerialNumberExists(serial): existing.append(serial) if len(existing) > 0: @@ -1033,7 +1045,7 @@ class StockItemCreate(AjaxCreateView): for serial in serials: # Create a new stock item for each serial number item = StockItem( - part=self.part, + part=part, quantity=1, serial=serial, supplier_part=form_data.get('supplier_part'), From 7190a8ef6972feddfc82702d76a77b8d66cf7168 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 17:29:41 +1000 Subject: [PATCH 29/52] Serial number placeholder text for BuildComplete form --- InvenTree/InvenTree/forms.py | 4 ++++ InvenTree/build/forms.py | 7 +++++++ InvenTree/build/models.py | 13 +++++++++++++ InvenTree/build/views.py | 15 +++++++++++++++ InvenTree/stock/views.py | 4 ++++ 5 files changed, 43 insertions(+) diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index b4509446f6..ad4b810e32 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -34,6 +34,10 @@ class HelperForm(forms.ModelForm): Simply create a 'blank' layout for each available field. """ + self.rebuild_layout() + + def rebuild_layout(self): + layouts = [] for field in self.fields: diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index d9f497da4a..f7a91464d5 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -46,6 +46,13 @@ class ConfirmBuildForm(HelperForm): class CompleteBuildForm(HelperForm): """ Form for marking a Build as complete """ + field_prefix = { + 'serial_numbers': 'fa-hashtag', + } + + field_placeholder = { + } + location = forms.ModelChoiceField( queryset=StockLocation.objects.all(), help_text='Location of completed parts', diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 54094632c1..e75fc88946 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -53,6 +53,19 @@ class Build(MPTTModel): def get_absolute_url(self): return reverse('build-detail', kwargs={'pk': self.id}) + def clean(self): + """ + Validation for Build object. + """ + + super().clean() + + if self.part.trackable: + if not self.quantity == int(self.quantity): + raise ValidationError({ + 'quantity': _("Build quantity must be integer value for trackable parts") + }) + title = models.CharField( verbose_name=_('Build Title'), blank=False, diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 96eb602f2b..6ef455aa70 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -196,6 +196,20 @@ class BuildComplete(AjaxUpdateView): if not build.part.trackable: form.fields.pop('serial_numbers') + else: + sn = build.part.getNextSerialNumber() + + if build.quantity > 1: + sn = "{n}-{m}".format( + n=str(sn), + m=str(sn+build.quantity-1) + ) + else: + sn = str(sn) + + form.field_placeholder['serial_numbers'] = sn + + form.rebuild_layout() return form @@ -208,6 +222,7 @@ class BuildComplete(AjaxUpdateView): initials = super(BuildComplete, self).get_initial().copy() build = self.get_object() + if build.part.default_location is not None: try: location = StockLocation.objects.get(pk=build.part.default_location.id) diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 6493a9d381..b096f2a24f 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -876,6 +876,8 @@ class StockItemCreate(AjaxCreateView): sn = part.getNextSerialNumber() form.field_placeholder['serial_numbers'] = _('Next available serial number is') + ' ' + str(sn) + form.rebuild_layout() + # Hide the 'part' field (as a valid part is selected) form.fields['part'].widget = HiddenInput() @@ -998,6 +1000,8 @@ class StockItemCreate(AjaxCreateView): sn = part.getNextSerialNumber() form.field_placeholder['serial_numbers'] = _("Next available serial number is") + " " + str(sn) + form.rebuild_layout() + except (Part.DoesNotExist, ValueError, InvalidOperation): part = None quantity = 1 From 40735d66a1532615161559cb8bd5d0d7912fa009 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 17:32:20 +1000 Subject: [PATCH 30/52] Translation tweaks --- InvenTree/build/forms.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index f7a91464d5..f60c257cef 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -55,10 +55,14 @@ class CompleteBuildForm(HelperForm): location = forms.ModelChoiceField( queryset=StockLocation.objects.all(), - help_text='Location of completed parts', + help_text=_('Location of completed parts'), ) - serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text=_('Enter unique serial numbers (or leave blank)')) + serial_numbers = forms.CharField( + label=_('Serial numbers'), + required=False, + help_text=_('Enter unique serial numbers (or leave blank)') + ) confirm = forms.BooleanField(required=False, help_text=_('Confirm build completion')) From a6ad263ee75447155d247c6f09f354bab48abfa4 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 17:43:32 +1000 Subject: [PATCH 31/52] Fix clean functions so unit tests pass --- InvenTree/build/models.py | 13 ++++++++----- InvenTree/stock/models.py | 22 +++++++++++++++------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index e75fc88946..23043d077f 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -60,11 +60,14 @@ class Build(MPTTModel): super().clean() - if self.part.trackable: - if not self.quantity == int(self.quantity): - raise ValidationError({ - 'quantity': _("Build quantity must be integer value for trackable parts") - }) + try: + if self.part.trackable: + if not self.quantity == int(self.quantity): + raise ValidationError({ + 'quantity': _("Build quantity must be integer value for trackable parts") + }) + except PartModels.Part.DoesNotExist: + pass title = models.CharField( verbose_name=_('Build Title'), diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 7ba7aa1e26..bfdb8461e2 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -215,6 +215,8 @@ class StockItem(MPTTModel): - Quantity must be 1 if the StockItem has a serial number """ + super().clean() + if self.status == StockStatus.SHIPPED and self.sales_order is None: raise ValidationError({ 'sales_order': "SalesOrder must be specified as status is marked as SHIPPED", @@ -227,14 +229,20 @@ class StockItem(MPTTModel): 'status': 'Status cannot be marked as ASSIGNED_TO_OTHER_ITEM if the belongs_to field is not set', }) - if self.part.trackable: - # Trackable parts must have integer values for quantity field! - if not self.quantity == int(self.quantity): - raise ValidationError({ - 'quantity': _('Quantity must be integer value for trackable parts') - }) + try: + if self.part.trackable: + # Trackable parts must have integer values for quantity field! + if not self.quantity == int(self.quantity): + raise ValidationError({ + 'quantity': _('Quantity must be integer value for trackable parts') + }) + except PartModels.Part.DoesNotExist: + # For some reason the 'clean' process sometimes throws errors because self.part does not exist + # It *seems* that this only occurs in unit testing, though. + # Probably should investigate this at some point. + pass - if self.quantity <= 0: + if self.quantity < 0: raise ValidationError({ 'quantity': _('Quantity must be greater than zero') }) From 8a99062704c307b3a527ce82f50e54224c2ea78d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 17:52:25 +1000 Subject: [PATCH 32/52] PEP fixes --- InvenTree/build/views.py | 15 +++++---------- InvenTree/order/forms.py | 3 --- InvenTree/part/models.py | 18 ++++++++++++++++++ InvenTree/stock/forms.py | 21 +-------------------- InvenTree/stock/tests.py | 1 - InvenTree/stock/views.py | 12 ++---------- 6 files changed, 26 insertions(+), 44 deletions(-) diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 6ef455aa70..2124207c7d 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -197,17 +197,12 @@ class BuildComplete(AjaxUpdateView): if not build.part.trackable: form.fields.pop('serial_numbers') else: - sn = build.part.getNextSerialNumber() - - if build.quantity > 1: - sn = "{n}-{m}".format( - n=str(sn), - m=str(sn+build.quantity-1) - ) + if build.quantity == 1: + text = _('Next available serial number is') else: - sn = str(sn) - - form.field_placeholder['serial_numbers'] = sn + text = _('Next available serial numbers are') + + form.field_placeholder['serial_numbers'] = text + " " + build.part.getSerialNumberString(build.quantity) form.rebuild_layout() diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 2887ef8892..ca61bd77be 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -8,9 +8,6 @@ from __future__ import unicode_literals from django import forms from django.utils.translation import ugettext as _ -from crispy_forms.layout import Field, Layout -from crispy_forms.bootstrap import PrependedText - from mptt.fields import TreeNodeChoiceField from InvenTree.forms import HelperForm diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 31771dc291..7639b9c25b 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -308,6 +308,24 @@ class Part(MPTTModel): else: return n + 1 + def getSerialNumberString(self, quantity): + """ + Return a formatted string representing the next available serial numbers, + given a certain quantity of items. + """ + + sn = self.getNextSerialNumber() + + if quantity >= 2: + sn = "{n}-{m}".format( + n=sn, + m=int(sn + quantity - 1) + ) + else: + sn = str(sn) + + return sn + @property def full_name(self): """ Format a 'full name' for this Part. diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 520ac72835..98a4de56d6 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -9,9 +9,6 @@ from django import forms from django.forms.utils import ErrorDict from django.utils.translation import ugettext as _ -from crispy_forms.layout import Field, Layout -from crispy_forms.bootstrap import PrependedText - from mptt.fields import TreeNodeChoiceField from InvenTree.helpers import GetExportFormats @@ -111,23 +108,7 @@ class SerializeStockForm(HelperForm): item = kwargs.pop('item', None) if item: - - # Pre-calculate what the serial numbers should be! - sn = item.part.getNextSerialNumber() - - if item.quantity >= 2: - sn = "{n}-{m}".format(n=sn, m=int(sn+item.quantity-1)) - else: - sn = str(sn) - - - self.field_placeholder = { - 'serial_numbers': sn - } - - self.field_prefix = { - 'serial_numbers': 'fa-hashtag', - } + self.field_placeholder['serial_numbers'] = item.part.getSerialNumberString(item.quantity) super().__init__(*args, **kwargs) diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index ab038c3c62..edb9660000 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -308,7 +308,6 @@ class StockTest(TestCase): item.delete_on_deplete = True item.save() - n = StockItem.objects.filter(part=25).count() self.assertEqual(item.quantity, 10) diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index b096f2a24f..dc70cc6bfb 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -755,7 +755,7 @@ class StockItemSerialize(AjaxUpdateView): model = StockItem ajax_template_name = 'stock/item_serialize.html' ajax_form_title = _('Serialize Stock') - #form_class = SerializeStockForm + form_class = SerializeStockForm def get_form(self): @@ -774,16 +774,8 @@ class StockItemSerialize(AjaxUpdateView): item = self.get_object() - # Pre-calculate what the serial numbers should be! - sn = item.part.getNextSerialNumber() - - if item.quantity >= 2: - sn = "{n}-{m}".format(n=sn, m=int(sn+item.quantity-1)) - else: - sn = str(sn) - initials['quantity'] = item.quantity - initials['serial_numbers'] = sn + initials['serial_numbers'] = item.part.getSerialNumberString(item.quantity) initials['destination'] = item.location.pk return initials From f39f5e5825b4cdbbf174adf9ddef4dbb032ceddf Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 19:56:18 +1000 Subject: [PATCH 33/52] Add StockItemTestResult model --- .../migrations/0040_stockitemtestresult.py | 29 +++++++ InvenTree/stock/models.py | 76 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 InvenTree/stock/migrations/0040_stockitemtestresult.py diff --git a/InvenTree/stock/migrations/0040_stockitemtestresult.py b/InvenTree/stock/migrations/0040_stockitemtestresult.py new file mode 100644 index 0000000000..fdf0344925 --- /dev/null +++ b/InvenTree/stock/migrations/0040_stockitemtestresult.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.5 on 2020-05-16 09:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('stock', '0039_auto_20200513_0016'), + ] + + operations = [ + migrations.CreateModel( + name='StockItemTestResult', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('test', models.CharField(help_text='Test name', max_length=100, verbose_name='Test')), + ('result', models.BooleanField(default=False, help_text='Test result', verbose_name='Result')), + ('value', models.CharField(blank=True, help_text='Test output value', max_length=500, verbose_name='Value')), + ('date', models.DateTimeField(auto_now_add=True)), + ('attachment', models.ForeignKey(blank=True, help_text='Test result attachment', null=True, on_delete=django.db.models.deletion.SET_NULL, to='stock.StockItemAttachment', verbose_name='Attachment')), + ('stock_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='test_results', to='stock.StockItem')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index bfdb8461e2..0d113e8d9e 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -993,3 +993,79 @@ class StockItemTracking(models.Model): # TODO # file = models.FileField() + + +class StockItemTestResult(models.Model): + """ + A StockItemTestResult records results of custom tests against individual StockItem objects. + This is useful for tracking unit acceptance tests, and particularly useful when integrated + with automated testing setups. + + Multiple results can be recorded against any given test, allowing tests to be run many times. + + Attributes: + stock_item: Link to StockItem + test: Test name (simple string matching) + result: Test result value (pass / fail / etc) + value: Recorded test output value (optional) + attachment: Link to StockItem attachment (optional) + user: User who uploaded the test result + date: Date the test result was recorded + """ + + def clean(self): + + super().clean() + + # If an attachment is linked to this result, the attachment must also point to the item + try: + if self.attachment: + if not self.attachment.stock_item == self.stock_item: + raise ValidationError({ + 'attachment': _("Test result attachment must be linked to the same StockItem"), + }) + except (StockItem.DoesNotExist, StockItemAttachment.DoesNotExist): + pass + + stock_item = models.ForeignKey( + StockItem, + on_delete=models.CASCADE, + related_name='test_results' + ) + + test = models.CharField( + blank=False, max_length=100, + verbose_name=_('Test'), + help_text=_('Test name') + ) + + result = models.BooleanField( + default=False, + verbose_name=_('Result'), + help_text=_('Test result') + ) + + value = models.CharField( + blank=True, max_length=500, + verbose_name=_('Value'), + help_text=_('Test output value') + ) + + attachment = models.ForeignKey( + StockItemAttachment, + on_delete=models.SET_NULL, + blank=True, null=True, + verbose_name=_('Attachment'), + help_text=_('Test result attachment'), + ) + + user = models.ForeignKey( + User, + on_delete = models.SET_NULL, + blank=True, null=True + ) + + date = models.DateTimeField( + auto_now_add=True, + editable=False + ) \ No newline at end of file From 2bb9fd9955a98edeeedb656b0ba27dd291c371b8 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 20:27:01 +1000 Subject: [PATCH 34/52] Add some unit testing for the new model --- InvenTree/stock/fixtures/stock.yaml | 12 +++++++ InvenTree/stock/fixtures/stock_tests.yaml | 31 +++++++++++++++++++ .../0041_stockitemtestresult_notes.py | 18 +++++++++++ InvenTree/stock/models.py | 25 +++++++++++++++ InvenTree/stock/tests.py | 29 ++++++++++++++--- 5 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 InvenTree/stock/fixtures/stock_tests.yaml create mode 100644 InvenTree/stock/migrations/0041_stockitemtestresult_notes.py diff --git a/InvenTree/stock/fixtures/stock.yaml b/InvenTree/stock/fixtures/stock.yaml index cac34fb01b..156aa8db53 100644 --- a/InvenTree/stock/fixtures/stock.yaml +++ b/InvenTree/stock/fixtures/stock.yaml @@ -70,6 +70,18 @@ lft: 0 rght: 0 +- model: stock.stockitem + pk: 105 + fields: + part: 25 + location: 7 + quantity: 1 + serial: 1000 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + # Stock items for template / variant parts - model: stock.stockitem pk: 500 diff --git a/InvenTree/stock/fixtures/stock_tests.yaml b/InvenTree/stock/fixtures/stock_tests.yaml new file mode 100644 index 0000000000..9a82b6ada7 --- /dev/null +++ b/InvenTree/stock/fixtures/stock_tests.yaml @@ -0,0 +1,31 @@ +- model: stock.stockitemtestresult + fields: + stock_item: 105 + test: "Firmware Version" + value: "0xA1B2C3D4" + result: True + date: 2020-02-02 + +- model: stock.stockitemtestresult + fields: + stock_item: 105 + test: "Settings Checksum" + value: "0xAABBCCDD" + result: True + date: 2020-02-02 + +- model: stock.stockitemtestresult + fields: + stock_item: 105 + test: "Temperature Test" + result: False + date: 2020-05-16 + notes: 'Got too hot or something' + +- model: stock.stockitemtestresult + fields: + stock_item: 105 + test: "Temperature Test" + result: True + date: 2020-05-17 + notes: 'Passed temperature test by making it cooler' \ No newline at end of file diff --git a/InvenTree/stock/migrations/0041_stockitemtestresult_notes.py b/InvenTree/stock/migrations/0041_stockitemtestresult_notes.py new file mode 100644 index 0000000000..1f258a8f6f --- /dev/null +++ b/InvenTree/stock/migrations/0041_stockitemtestresult_notes.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.5 on 2020-05-16 10:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0040_stockitemtestresult'), + ] + + operations = [ + migrations.AddField( + model_name='stockitemtestresult', + name='notes', + field=models.CharField(blank=True, help_text='Test notes', max_length=500, verbose_name='Notes'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 0d113e8d9e..e6571fa89e 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -921,6 +921,24 @@ class StockItem(MPTTModel): return s + def getTestResults(self, test=None, result=None, user=None): + + results = self.test_results + + if test: + # Filter by test name + results = results.filter(test=test) + + if result is not None: + # Filter by test status + results = results.filter(result=result) + + if user: + # Filter by user + results = results.filter(user=user) + + return results + @receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log') def before_delete_stock_item(sender, instance, using, **kwargs): @@ -1009,6 +1027,7 @@ class StockItemTestResult(models.Model): result: Test result value (pass / fail / etc) value: Recorded test output value (optional) attachment: Link to StockItem attachment (optional) + notes: Extra user notes related to the test (optional) user: User who uploaded the test result date: Date the test result was recorded """ @@ -1059,6 +1078,12 @@ class StockItemTestResult(models.Model): help_text=_('Test result attachment'), ) + notes = models.CharField( + blank=True, max_length=500, + verbose_name=_('Notes'), + help_text=_("Test notes"), + ) + user = models.ForeignKey( User, on_delete = models.SET_NULL, diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index edb9660000..2c8640d48a 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -17,6 +17,7 @@ class StockTest(TestCase): 'part', 'location', 'stock', + 'stock_tests', ] def setUp(self): @@ -95,8 +96,8 @@ class StockTest(TestCase): self.assertFalse(self.drawer2.has_items()) # Drawer 3 should have three stock items - self.assertEqual(self.drawer3.stock_items.count(), 15) - self.assertEqual(self.drawer3.item_count, 15) + self.assertEqual(self.drawer3.stock_items.count(), 16) + self.assertEqual(self.drawer3.item_count, 16) def test_stock_count(self): part = Part.objects.get(pk=1) @@ -108,7 +109,7 @@ class StockTest(TestCase): self.assertEqual(part.total_stock, 9000) # There should be 18 widgets in stock - self.assertEqual(StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 18) + self.assertEqual(StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 19) def test_delete_location(self): @@ -168,12 +169,12 @@ class StockTest(TestCase): self.assertEqual(w1.quantity, 4) # There should also be a new object still in drawer3 - self.assertEqual(StockItem.objects.filter(part=25).count(), 4) + self.assertEqual(StockItem.objects.filter(part=25).count(), 5) widget = StockItem.objects.get(location=self.drawer3.id, part=25, quantity=4) # Try to move negative units self.assertFalse(widget.move(self.bathroom, 'Test', None, quantity=-100)) - self.assertEqual(StockItem.objects.filter(part=25).count(), 4) + self.assertEqual(StockItem.objects.filter(part=25).count(), 5) # Try to move to a blank location self.assertFalse(widget.move(None, 'null', None)) @@ -404,3 +405,21 @@ class VariantTest(StockTest): item.serial += 1 item.save() + + +class TestResultTest(StockTest): + """ + Tests for the StockItemTestResult model. + """ + + def test_test_count(self): + item = StockItem.objects.get(pk=105) + tests = item.test_results + self.assertEqual(tests.count(), 4) + + results = item.getTestResults(test="Temperature Test") + self.assertEqual(results.count(), 2) + + # Passing tests + self.assertEqual(item.getTestResults(result=True).count(), 3) + self.assertEqual(item.getTestResults(result=False).count(), 1) From df91b8cf4d40fff197d23595044b76909d543767 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 20:45:10 +1000 Subject: [PATCH 35/52] Add function to get all test results as a map - This will be required for pushing out to a test report --- InvenTree/stock/models.py | 27 +++++++++++++++++++++++++++ InvenTree/stock/serializers.py | 19 +++++++++++++++++++ InvenTree/stock/tests.py | 8 ++++++++ 3 files changed, 54 insertions(+) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index e6571fa89e..4ccbd74b5a 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -922,6 +922,14 @@ class StockItem(MPTTModel): return s def getTestResults(self, test=None, result=None, user=None): + """ + Return all test results associated with this StockItem. + + Optionally can filter results by: + - Test name + - Test result + - User + """ results = self.test_results @@ -939,6 +947,25 @@ class StockItem(MPTTModel): return results + def testResultMap(self, **kwargs): + """ + Return a map of test-results using the test name as the key. + Where multiple test results exist for a given name, + the *most recent* test is used. + + This map is useful for rendering to a template (e.g. a test report), + as all named tests are accessible. + """ + + results = self.getTestResults(**kwargs).order_by('-date') + + result_map = {} + + for result in results: + result_map[result.test] = result + + return result_map + @receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log') def before_delete_stock_item(sender, instance, using, **kwargs): diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 622f93e620..ad53da200d 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -7,6 +7,7 @@ from rest_framework import serializers from .models import StockItem, StockLocation from .models import StockItemTracking from .models import StockItemAttachment +from .models import StockItemTestResult from django.db.models import Sum, Count from django.db.models.functions import Coalesce @@ -216,6 +217,24 @@ class StockItemAttachmentSerializer(InvenTreeModelSerializer): ] +class StockItemTestResultSerializer(InvenTreeModelSerializer): + """ Serializer for the StockItemTestResult model """ + + class Meta: + model = StockItemTestResult + + fields = [ + 'pk', + 'test', + 'result', + 'value', + 'attachment', + 'notes', + 'user', + 'date' + ] + + class StockTrackingSerializer(InvenTreeModelSerializer): """ Serializer for StockItemTracking model """ diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index 2c8640d48a..a2bb4d4bb4 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -423,3 +423,11 @@ class TestResultTest(StockTest): # Passing tests self.assertEqual(item.getTestResults(result=True).count(), 3) self.assertEqual(item.getTestResults(result=False).count(), 1) + + # Result map + result_map = item.testResultMap() + + self.assertEqual(len(result_map), 3) + + for test in ['Firmware Version', 'Settings Checksum', 'Temperature Test']: + self.assertIn(test, result_map.keys()) \ No newline at end of file From 880a0a4af884abb0e7791d3e7542b4e2365e6695 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 20:55:30 +1000 Subject: [PATCH 36/52] Add StockItemTestResult to the admin interface --- InvenTree/stock/admin.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/InvenTree/stock/admin.py b/InvenTree/stock/admin.py index 86099cdbac..333c7d35c1 100644 --- a/InvenTree/stock/admin.py +++ b/InvenTree/stock/admin.py @@ -10,6 +10,7 @@ import import_export.widgets as widgets from .models import StockLocation, StockItem, StockItemAttachment from .models import StockItemTracking +from .models import StockItemTestResult from build.models import Build from company.models import SupplierPart @@ -117,7 +118,13 @@ class StockTrackingAdmin(ImportExportModelAdmin): list_display = ('item', 'date', 'title') +class StockItemTestResultAdmin(admin.ModelAdmin): + + list_display = ('stock_item', 'test', 'result', 'value') + + admin.site.register(StockLocation, LocationAdmin) admin.site.register(StockItem, StockItemAdmin) admin.site.register(StockItemTracking, StockTrackingAdmin) admin.site.register(StockItemAttachment, StockAttachmentAdmin) +admin.site.register(StockItemTestResult, StockItemTestResultAdmin) From f3c71bd96f0de690be83a46de15ad2d693f6e9e6 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 20:56:36 +1000 Subject: [PATCH 37/52] API interface for StockItemTestResult --- InvenTree/stock/api.py | 34 ++++++++++++++++++++++++++++++++++ InvenTree/stock/serializers.py | 1 + 2 files changed, 35 insertions(+) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 1c654d8b3c..f0fb8a5d7a 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -15,6 +15,7 @@ from django.db.models import Q from .models import StockLocation, StockItem from .models import StockItemTracking from .models import StockItemAttachment +from .models import StockItemTestResult from part.models import Part, PartCategory from part.serializers import PartBriefSerializer @@ -26,6 +27,7 @@ from .serializers import StockItemSerializer from .serializers import LocationSerializer, LocationBriefSerializer from .serializers import StockTrackingSerializer from .serializers import StockItemAttachmentSerializer +from .serializers import StockItemTestResultSerializer from InvenTree.views import TreeSerializer from InvenTree.helpers import str2bool, isNull @@ -659,6 +661,33 @@ class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin): ] +class StockItemTestResultList(generics.ListCreateAPIView): + """ + API endpoint for listing (and creating) a StockItemTestResult object. + """ + + queryset = StockItemTestResult.objects.all() + serializer_class = StockItemTestResultSerializer + + permission_classes = [ + permissions.IsAuthenticated, + ] + + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + + filter_fields = [ + 'stock_item', + 'test', + 'user', + 'result', + 'value', + ] + + class StockTrackingList(generics.ListCreateAPIView): """ API endpoint for list view of StockItemTracking objects. @@ -769,6 +798,11 @@ stock_api_urls = [ url(r'^$', StockAttachmentList.as_view(), name='api-stock-attachment-list'), ])), + # Base URL for StockItemTestResult API endpoints + url(r'^test/', include([ + url(r'^$', StockItemTestResultList.as_view(), name='api-stock-test-result-list'), + ])), + url(r'track/?', StockTrackingList.as_view(), name='api-stock-track'), url(r'^tree/?', StockCategoryTree.as_view(), name='api-stock-tree'), diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index ad53da200d..8459aefd2a 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -225,6 +225,7 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): fields = [ 'pk', + 'stock_item', 'test', 'result', 'value', From fe99e92bfcbca1c7084936690c30ddf672a375b1 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 21:02:25 +1000 Subject: [PATCH 38/52] Refactor table_filters javascript file to prevent loading on every single page --- InvenTree/InvenTree/urls.py | 1 + InvenTree/templates/base.html | 3 +-- InvenTree/templates/{ => js}/table_filters.html | 3 --- 3 files changed, 2 insertions(+), 5 deletions(-) rename InvenTree/templates/{ => js}/table_filters.html (98%) diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 1220f09ddc..711df33798 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -81,6 +81,7 @@ dynamic_javascript_urls = [ url(r'^order.js', DynamicJsView.as_view(template_name='js/order.html'), name='order.js'), url(r'^company.js', DynamicJsView.as_view(template_name='js/company.html'), name='company.js'), url(r'^bom.js', DynamicJsView.as_view(template_name='js/bom.html'), name='bom.js'), + url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/table_filters.html'), name='table_filters.js'), ] urlpatterns = [ diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index fbe8398f27..63487eb625 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -114,6 +114,7 @@ InvenTree + @@ -122,8 +123,6 @@ InvenTree {% block js_load %} {% endblock %} -{% include "table_filters.html" %} - \ No newline at end of file From a17ab9bfbdf81b7e2d7227853fa225c67f2d1f02 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 21:10:27 +1000 Subject: [PATCH 39/52] Add an (empty) page for dispalying test results --- .../stock/templates/stock/item_tests.html | 20 +++++++++++++++++++ InvenTree/stock/templates/stock/tabs.html | 8 ++++++++ InvenTree/stock/urls.py | 1 + 3 files changed, 29 insertions(+) create mode 100644 InvenTree/stock/templates/stock/item_tests.html diff --git a/InvenTree/stock/templates/stock/item_tests.html b/InvenTree/stock/templates/stock/item_tests.html new file mode 100644 index 0000000000..e9835f6071 --- /dev/null +++ b/InvenTree/stock/templates/stock/item_tests.html @@ -0,0 +1,20 @@ +{% extends "stock/item_base.html" %} + +{% load static %} +{% load i18n %} + +{% block details %} + +{% include "stock/tabs.html" with tab='tests' %} + +
+

{% trans "Test Results" %}

+ +
+ +{% endblock %} + +{% block js_ready %} +{{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/stock/templates/stock/tabs.html b/InvenTree/stock/templates/stock/tabs.html index fddd2f095f..ce14511b8a 100644 --- a/InvenTree/stock/templates/stock/tabs.html +++ b/InvenTree/stock/templates/stock/tabs.html @@ -10,6 +10,14 @@ {% if item.tracking_info.count > 0 %}{{ item.tracking_info.count }}{% endif %} + {% if item.part.trackable %} + + + {% trans "Test Results" %} + {% if item.test_results.count > 0 %}{{ item.test_results.count }}{% endif %} + + + {% endif %} {% if 0 %} diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 149c0dde2f..f0b3091b84 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -24,6 +24,7 @@ stock_item_detail_urls = [ url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'), + url(r'^test/', views.StockItemDetail.as_view(template_name='stock/item_tests.html'), name='stock-item-test-results'), url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'), url(r'^attachments/', views.StockItemDetail.as_view(template_name='stock/item_attachments.html'), name='stock-item-attachments'), url(r'^notes/', views.StockItemNotes.as_view(), name='stock-item-notes'), From d6a56da4419742b7305c3a4a52108833945102a7 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 21:15:14 +1000 Subject: [PATCH 40/52] PEP fixes --- InvenTree/stock/models.py | 4 ++-- InvenTree/stock/tests.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 4ccbd74b5a..a73c7e0a76 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1113,11 +1113,11 @@ class StockItemTestResult(models.Model): user = models.ForeignKey( User, - on_delete = models.SET_NULL, + on_delete=models.SET_NULL, blank=True, null=True ) date = models.DateTimeField( auto_now_add=True, editable=False - ) \ No newline at end of file + ) diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index a2bb4d4bb4..8fce455a97 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -430,4 +430,4 @@ class TestResultTest(StockTest): self.assertEqual(len(result_map), 3) for test in ['Firmware Version', 'Settings Checksum', 'Temperature Test']: - self.assertIn(test, result_map.keys()) \ No newline at end of file + self.assertIn(test, result_map.keys()) From 247cfcc514ea8fef7e769ab3f50e320b505c8e6e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 21:57:41 +1000 Subject: [PATCH 41/52] Add API unit tests --- InvenTree/stock/test_api.py | 104 +++++++++++++++++++++++++----------- 1 file changed, 73 insertions(+), 31 deletions(-) diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index fe49547cee..afb24487f1 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -6,11 +6,17 @@ from django.contrib.auth import get_user_model from .models import StockLocation -class StockLocationTest(APITestCase): - """ - Series of API tests for the StockLocation API - """ - list_url = reverse('api-location-list') +class StockAPITestCase(APITestCase): + + fixtures = [ + 'category', + 'part', + 'company', + 'location', + 'supplier_part', + 'stock', + 'stock_tests', + ] def setUp(self): # Create a user for auth @@ -18,6 +24,21 @@ class StockLocationTest(APITestCase): User.objects.create_user('testuser', 'test@testing.com', 'password') self.client.login(username='testuser', password='password') + def doPost(self, url, data={}): + response = self.client.post(url, data=data, format='json') + + return response + + +class StockLocationTest(StockAPITestCase): + """ + Series of API tests for the StockLocation API + """ + list_url = reverse('api-location-list') + + def setUp(self): + super().setUp() + # Add some stock locations StockLocation.objects.create(name='top', description='top category') @@ -38,7 +59,7 @@ class StockLocationTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) -class StockItemTest(APITestCase): +class StockItemTest(StockAPITestCase): """ Series of API tests for the StockItem API """ @@ -49,11 +70,7 @@ class StockItemTest(APITestCase): return reverse('api-stock-detail', kwargs={'pk': pk}) def setUp(self): - # Create a user for auth - User = get_user_model() - User.objects.create_user('testuser', 'test@testing.com', 'password') - self.client.login(username='testuser', password='password') - + super().setUp() # Create some stock locations top = StockLocation.objects.create(name='A', description='top') @@ -65,30 +82,11 @@ class StockItemTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) -class StocktakeTest(APITestCase): +class StocktakeTest(StockAPITestCase): """ Series of tests for the Stocktake API """ - fixtures = [ - 'category', - 'part', - 'company', - 'location', - 'supplier_part', - 'stock', - ] - - def setUp(self): - User = get_user_model() - User.objects.create_user('testuser', 'test@testing.com', 'password') - self.client.login(username='testuser', password='password') - - def doPost(self, url, data={}): - response = self.client.post(url, data=data, format='json') - - return response - def test_action(self): """ Test each stocktake action endpoint, @@ -179,3 +177,47 @@ class StocktakeTest(APITestCase): response = self.doPost(url, data) self.assertContains(response, 'Valid location must be specified', status_code=status.HTTP_400_BAD_REQUEST) + + +class StockTestResultTest(StockAPITestCase): + + def test_list(self): + + url = reverse('api-stock-test-result-list') + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertGreaterEqual(len(response.data), 4) + + response = self.client.get(url, data={'stock_item': 105}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertGreaterEqual(len(response.data), 4) + + def test_post(self): + # Test creation of a new test result + + url = reverse('api-stock-test-result-list') + + response = self.client.get(url) + n = len(response.data) + + data = { + 'stock_item': 105, + 'test': 'Checked Steam Valve', + 'result': False, + 'value': '150kPa', + 'notes': 'I guess there was just too much pressure?', + } + + response = self.client.post(url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.get(url) + self.assertEqual(len(response.data), n + 1) + + # And read out again + response = self.client.get(url, data={'test': 'Checked Steam Valve'}) + + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['value'], '150kPa') From 45556058d22ebabbfc9ae361c54041ae60139a5f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 22:53:23 +1000 Subject: [PATCH 42/52] Allow files to be uploaded alongside a test report - Attach the file to the stock item - Link the uploaded attachment to the test report --- InvenTree/stock/api.py | 30 +++++++++++++++++++++++++ InvenTree/stock/serializers.py | 9 ++++++++ InvenTree/stock/test_api.py | 41 +++++++++++++++++++++++++++++++--- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index f0fb8a5d7a..f6218547d0 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -687,6 +687,36 @@ class StockItemTestResultList(generics.ListCreateAPIView): 'value', ] + def perform_create(self, serializer): + """ + Create a new test result object. + + Also, check if an attachment was uploaded alongside the test result, + and save it to the database if it were. + """ + + # Capture the user information + test_result = serializer.save() + test_result.user = self.request.user + + # Check if a file has been attached to the request + attachment_file = self.request.FILES.get('attachment', None) + + if attachment_file: + # Create a new attachment associated with the stock item + attachment = StockItemAttachment( + attachment=attachment_file, + stock_item=test_result.stock_item, + user=test_result.user + ) + + attachment.save() + + # Link the attachment back to the test result + test_result.attachment = attachment + + test_result.save() + class StockTrackingList(generics.ListCreateAPIView): """ API endpoint for list view of StockItemTracking objects. diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 8459aefd2a..783ff70d4b 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -212,6 +212,7 @@ class StockItemAttachmentSerializer(InvenTreeModelSerializer): 'stock_item', 'attachment', 'comment', + 'date', 'user', 'user_detail', ] @@ -235,6 +236,14 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): 'date' ] + read_only_fields = [ + 'pk', + 'stock_item', + 'attachment', + 'user', + 'date', + ] + class StockTrackingSerializer(InvenTreeModelSerializer): """ Serializer for StockItemTracking model """ diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index afb24487f1..cd598e8538 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -181,9 +181,12 @@ class StocktakeTest(StockAPITestCase): class StockTestResultTest(StockAPITestCase): + def get_url(self): + return reverse('api-stock-test-result-list') + def test_list(self): - url = reverse('api-stock-test-result-list') + url = self.get_url() response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -193,10 +196,39 @@ class StockTestResultTest(StockAPITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertGreaterEqual(len(response.data), 4) + def test_post_fail(self): + # Attempt to post a new test result without specifying required data + + url = self.get_url() + + response = self.client.post( + url, + data={ + 'test': 'A test', + 'result': True, + }, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # This one should pass! + response = self.client.post( + url, + data={ + 'test': 'A test', + 'stock_item': 105, + 'result': True, + }, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + def test_post(self): # Test creation of a new test result - url = reverse('api-stock-test-result-list') + url = self.get_url() response = self.client.get(url) n = len(response.data) @@ -220,4 +252,7 @@ class StockTestResultTest(StockAPITestCase): response = self.client.get(url, data={'test': 'Checked Steam Valve'}) self.assertEqual(len(response.data), 1) - self.assertEqual(response.data[0]['value'], '150kPa') + + test = response.data[0] + self.assertEqual(test['value'], '150kPa') + self.assertEqual(test['user'], 1) From fd11317409d5700b95ce79b2b48150fde7cd4aca Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 23:04:37 +1000 Subject: [PATCH 43/52] Whoops --- InvenTree/stock/serializers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 783ff70d4b..1cb7a88df8 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -238,7 +238,6 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): read_only_fields = [ 'pk', - 'stock_item', 'attachment', 'user', 'date', From 197a3e6731113fcda1ce71072e326fd828a791a6 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 23:12:43 +1000 Subject: [PATCH 44/52] Fixes for Stock API --- InvenTree/stock/api.py | 6 ++++++ InvenTree/stock/serializers.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index f6218547d0..2580f1d221 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -656,6 +656,12 @@ class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin): queryset = StockItemAttachment.objects.all() serializer_class = StockItemAttachmentSerializer + filter_backends = [ + DjangoFilterBackend, + filters.OrderingFilter, + filters.SearchFilter, + ] + filter_fields = [ 'stock_item', ] diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 1cb7a88df8..3ab285baf9 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -212,7 +212,7 @@ class StockItemAttachmentSerializer(InvenTreeModelSerializer): 'stock_item', 'attachment', 'comment', - 'date', + 'upload_date', 'user', 'user_detail', ] From e02b692ab2cbe47e80e14a1abbd6067ce1e9ccbe Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 23:19:16 +1000 Subject: [PATCH 45/52] Improve API filtering for StockItem --- InvenTree/stock/api.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 2580f1d221..342504b99a 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -538,11 +538,10 @@ class StockList(generics.ListCreateAPIView): try: part = Part.objects.get(pk=part_id) - # If the part is a Template part, select stock items for any "variant" parts under that template - if part.is_template: - queryset = queryset.filter(part__in=[part.id for part in Part.objects.filter(variant_of=part_id)]) - else: - queryset = queryset.filter(part=part_id) + # Filter by any parts "under" the given part + parts = part.get_descendants(include_self=True) + + queryset = queryset.filter(part__in=parts) except (ValueError, Part.DoesNotExist): raise ValidationError({"part": "Invalid Part ID specified"}) From 608057da6954bad38b873f30687df0f2350449d1 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 23:36:03 +1000 Subject: [PATCH 46/52] Add option for user_detail in stockitemtestresult serializer --- InvenTree/stock/api.py | 10 ++++++++++ InvenTree/stock/serializers.py | 13 ++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 342504b99a..0d257e606b 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -692,6 +692,16 @@ class StockItemTestResultList(generics.ListCreateAPIView): 'value', ] + def get_serializer(self, *args, **kwargs): + try: + kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False)) + except: + pass + + kwargs['context'] = self.get_serializer_context() + + return self.serializer_class(*args, **kwargs) + def perform_create(self, serializer): """ Create a new test result object. diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 3ab285baf9..5c33750fde 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -194,7 +194,7 @@ class LocationSerializer(InvenTreeModelSerializer): class StockItemAttachmentSerializer(InvenTreeModelSerializer): """ Serializer for StockItemAttachment model """ - def __init_(self, *args, **kwargs): + def __init__(self, *args, **kwargs): user_detail = kwargs.pop('user_detail', False) super().__init__(*args, **kwargs) @@ -221,6 +221,16 @@ class StockItemAttachmentSerializer(InvenTreeModelSerializer): class StockItemTestResultSerializer(InvenTreeModelSerializer): """ Serializer for the StockItemTestResult model """ + user_detail = UserSerializerBrief(source='user', read_only=True) + + def __init__(self, *args, **kwargs): + user_detail = kwargs.pop('user_detail', False) + + super().__init__(*args, **kwargs) + + if user_detail is not True: + self.fields.pop('user_detail') + class Meta: model = StockItemTestResult @@ -233,6 +243,7 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): 'attachment', 'notes', 'user', + 'user_detail', 'date' ] From 3b53437f462b58256b9fe4c45fcbeea864c6ee02 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 23:53:28 +1000 Subject: [PATCH 47/52] Add 'attachment_detail' to the TestResult serializer --- InvenTree/stock/api.py | 5 +++++ InvenTree/stock/serializers.py | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 0d257e606b..aad36cc3dc 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -698,6 +698,11 @@ class StockItemTestResultList(generics.ListCreateAPIView): except: pass + try: + kwargs['attachment_detail'] = str2bool(self.request.query_params.get('attachment_detail', False)) + except: + pass + kwargs['context'] = self.get_serializer_context() return self.serializer_class(*args, **kwargs) diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 5c33750fde..865a63a2c2 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -222,15 +222,20 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): """ Serializer for the StockItemTestResult model """ user_detail = UserSerializerBrief(source='user', read_only=True) + attachment_detail = StockItemAttachmentSerializer(source='attachment', read_only=True) def __init__(self, *args, **kwargs): user_detail = kwargs.pop('user_detail', False) + attachment_detail = kwargs.pop('attachment_detail', False) super().__init__(*args, **kwargs) if user_detail is not True: self.fields.pop('user_detail') + if attachment_detail is not True: + self.fields.pop('attachment_detail') + class Meta: model = StockItemTestResult @@ -241,6 +246,7 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): 'result', 'value', 'attachment', + 'attachment_detail', 'notes', 'user', 'user_detail', From e23a9c12699fbe90b2e725a730fbb25716635355 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 23:54:07 +1000 Subject: [PATCH 48/52] Load testresult table for stock item - Uses the API / bootstrap table - Is pretty! - Provides link to the attachment (if one exists) --- .../stock/templates/stock/item_tests.html | 21 +++- InvenTree/templates/js/stock.html | 104 ++++++++++++++++++ InvenTree/templates/js/table_filters.html | 10 ++ 3 files changed, 134 insertions(+), 1 deletion(-) diff --git a/InvenTree/stock/templates/stock/item_tests.html b/InvenTree/stock/templates/stock/item_tests.html index e9835f6071..9172bfdde4 100644 --- a/InvenTree/stock/templates/stock/item_tests.html +++ b/InvenTree/stock/templates/stock/item_tests.html @@ -10,11 +10,30 @@

{% trans "Test Results" %}

-
+
+
+ +
+ +
+
+
+ +
{% endblock %} {% block js_ready %} {{ block.super }} +loadStockTestResultsTable( + $("#test-result-table"), { + params: { + stock_item: {{ item.id }}, + user_detail: true, + attachment_detail: true, + }, + } +); + {% endblock %} \ No newline at end of file diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html index 386ac16bad..48d6cae6e0 100644 --- a/InvenTree/templates/js/stock.html +++ b/InvenTree/templates/js/stock.html @@ -18,6 +18,110 @@ function removeStockRow(e) { $('#' + row).remove(); } + +function passFailBadge(result) { + + if (result) { + return `{% trans "PASS" %}`; + } else { + return `{% trans "FAIL" %}`; + } +} + +function loadStockTestResultsTable(table, options) { + /* + * Load StockItemTestResult table + */ + + var params = options.params || {}; + + // HTML element to setup the filtering + var filterListElement = options.filterList || '#filter-list-stocktests'; + + var filters = {}; + + filters = loadTableFilters("stocktests"); + + var original = {}; + + for (var key in params) { + original[key] = params[key]; + } + + setupFilterList("stocktests", table, filterListElement); + + // Override the default values, or add new ones + for (var key in params) { + filters[key] = params[key]; + } + + table.inventreeTable({ + method: 'get', + formatNoMatches: function() { + return '{% trans "No test results matching query" %}'; + }, + url: "{% url 'api-stock-test-result-list' %}", + queryParams: filters, + original: original, + columns: [ + { + field: 'pk', + title: 'ID', + visible: false + }, + { + field: 'test', + title: '{% trans "Test" %}', + sortable: true, + formatter: function(value, row) { + var html = value; + + if (row.attachment_detail) { + html += ``; + } + + return html; + }, + }, + { + field: 'result', + title: "{% trans "Result" %}", + sortable: true, + formatter: function(value) { + return passFailBadge(value); + } + }, + { + field: 'value', + title: "{% trans "Value" %}", + sortable: true, + }, + { + field: 'notes', + title: '{% trans "Notes" %}', + }, + { + field: 'date', + title: '{% trans "Uploaded" %}', + sortable: true, + formatter: function(value, row) { + var html = value; + + if (row.user_detail) { + html += `${row.user_detail.username}`; + } + + return html; + } + }, + { + field: 'buttons', + }, + ] + }); +} + + function loadStockTable(table, options) { /* Load data into a stock table with adjustable options. * Fetches data (via AJAX) and loads into a bootstrap table. diff --git a/InvenTree/templates/js/table_filters.html b/InvenTree/templates/js/table_filters.html index efee6d221f..955703a7c9 100644 --- a/InvenTree/templates/js/table_filters.html +++ b/InvenTree/templates/js/table_filters.html @@ -37,6 +37,16 @@ function getAvailableTableFilters(tableKey) { }; } + // Filters for the 'stock test' table + if (tableKey == 'stocktests') { + return { + result: { + type: 'bool', + title: "{% trans 'Test result' %}", + }, + } + } + // Filters for the "Build" table if (tableKey == 'build') { return { From 05856a72cfdfc3344fd060f05b11a8dd9f7a6e16 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 16 May 2020 23:56:50 +1000 Subject: [PATCH 49/52] UI cleanup --- InvenTree/stock/templates/stock/item.html | 3 ++- InvenTree/stock/templates/stock/item_attachments.html | 2 +- InvenTree/stock/templates/stock/item_notes.html | 1 + InvenTree/stock/templates/stock/item_tests.html | 2 +- InvenTree/stock/templates/stock/tabs.html | 8 +++++--- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index 517acb15fe..f72c910981 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -8,8 +8,9 @@ {% include "stock/tabs.html" with tab="tracking" %} -

{% trans "Stock Tracking Information" %}

+
+
diff --git a/InvenTree/stock/templates/stock/item_attachments.html b/InvenTree/stock/templates/stock/item_attachments.html index 453ab7f09b..3861bebe6a 100644 --- a/InvenTree/stock/templates/stock/item_attachments.html +++ b/InvenTree/stock/templates/stock/item_attachments.html @@ -7,8 +7,8 @@ {% include "stock/tabs.html" with tab='attachments' %} -

{% trans "Stock Item Attachments" %}

+
{% include "attachment_table.html" with attachments=item.attachments.all %} diff --git a/InvenTree/stock/templates/stock/item_notes.html b/InvenTree/stock/templates/stock/item_notes.html index 31e818ef71..9dd21331b4 100644 --- a/InvenTree/stock/templates/stock/item_notes.html +++ b/InvenTree/stock/templates/stock/item_notes.html @@ -10,6 +10,7 @@ {% include "stock/tabs.html" with tab="notes" %} {% if editing %} +

{% trans "Stock Item Notes" %}


diff --git a/InvenTree/stock/templates/stock/item_tests.html b/InvenTree/stock/templates/stock/item_tests.html index 9172bfdde4..a3e21962e1 100644 --- a/InvenTree/stock/templates/stock/item_tests.html +++ b/InvenTree/stock/templates/stock/item_tests.html @@ -7,8 +7,8 @@ {% include "stock/tabs.html" with tab='tests' %} -

{% trans "Test Results" %}

+
diff --git a/InvenTree/stock/templates/stock/tabs.html b/InvenTree/stock/templates/stock/tabs.html index ce14511b8a..5ec75ddc28 100644 --- a/InvenTree/stock/templates/stock/tabs.html +++ b/InvenTree/stock/templates/stock/tabs.html @@ -1,9 +1,6 @@ {% load i18n %} \ No newline at end of file From 82fe497787f5d7364423cf292c8076f5f4f7e478 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 00:04:45 +1000 Subject: [PATCH 50/52] Add buttons to edit / delete a Test result - Don't do nuthin yet! --- InvenTree/build/templates/build/allocate.html | 6 +++--- .../templates/order/purchase_order_detail.html | 2 +- .../order/templates/order/sales_order_detail.html | 10 +++++----- InvenTree/templates/js/stock.html | 13 +++++++++++++ 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/InvenTree/build/templates/build/allocate.html b/InvenTree/build/templates/build/allocate.html index b90508a7d8..61cccae1bf 100644 --- a/InvenTree/build/templates/build/allocate.html +++ b/InvenTree/build/templates/build/allocate.html @@ -198,8 +198,8 @@ InvenTree | Allocate Parts var html = `
`; {% if build.status == BuildStatus.PENDING %} - html += makeIconButton('fa-edit', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); - html += makeIconButton('fa-trash-alt', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); + html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); + html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); {% endif %} html += `
`; @@ -389,7 +389,7 @@ InvenTree | Allocate Parts html += makeIconButton('fa-tools', 'button-build', pk, '{% trans "Build parts" %}'); } - html += makeIconButton('fa-plus', 'button-add', pk, '{% trans "Allocate stock" %}'); + html += makeIconButton('fa-plus icon-green', 'button-add', pk, '{% trans "Allocate stock" %}'); {% endif %} html += '
'; diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index eb796b2ec1..de066500b2 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -208,7 +208,7 @@ $("#po-table").inventreeTable({ var pk = row.pk; {% if order.status == PurchaseOrderStatus.PENDING %} - html += makeIconButton('fa-edit', 'button-line-edit', pk, '{% trans "Edit line item" %}'); + html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}'); html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}'); {% endif %} diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 0ca63882b2..75c5fc3d7b 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -89,8 +89,8 @@ function showAllocationSubTable(index, row, element) { var pk = row.pk; {% if order.status == SalesOrderStatus.PENDING %} - html += makeIconButton('fa-edit', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); - html += makeIconButton('fa-trash-alt', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); + html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); + html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); {% endif %} html += "
"; @@ -274,11 +274,11 @@ $("#so-lines-table").inventreeTable({ html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build parts" %}'); } - html += makeIconButton('fa-plus', 'button-add', pk, '{% trans "Allocate parts" %}'); + html += makeIconButton('fa-plus icon-green', 'button-add', pk, '{% trans "Allocate parts" %}'); } - html += makeIconButton('fa-edit', 'button-edit', pk, '{% trans "Edit line item" %}'); - html += makeIconButton('fa-trash-alt', 'button-delete', pk, '{% trans "Delete line item " %}'); + html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}'); + html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line item " %}'); html += `
`; diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html index 48d6cae6e0..af65782bef 100644 --- a/InvenTree/templates/js/stock.html +++ b/InvenTree/templates/js/stock.html @@ -116,6 +116,19 @@ function loadStockTestResultsTable(table, options) { }, { field: 'buttons', + formatter: function(value, row) { + + var pk = row.pk; + + var html = `
`; + + html += makeIconButton('fa-edit icon-blue', 'button-test-edit', pk, '{% trans "Edit test result" %}'); + html += makeIconButton('fa-trash-alt icon-red', 'button-test-delete', pk, '{% trans "Delete test result" %}'); + + html += `
`; + + return html; + } }, ] }); From 327682b719db455efe6a38d40d3295d33e484458 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 00:26:10 +1000 Subject: [PATCH 51/52] Add forms / views / etc to create / edit / delete test results manually --- InvenTree/stock/forms.py | 20 +++++- .../stock/templates/stock/item_tests.html | 39 ++++++++++- InvenTree/stock/urls.py | 8 +++ InvenTree/stock/views.py | 65 ++++++++++++++++++- InvenTree/templates/attachment_table.html | 2 +- 5 files changed, 130 insertions(+), 4 deletions(-) diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 98a4de56d6..0d543b1315 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -15,7 +15,9 @@ from InvenTree.helpers import GetExportFormats from InvenTree.forms import HelperForm from InvenTree.fields import RoundingDecimalFormField -from .models import StockLocation, StockItem, StockItemTracking, StockItemAttachment +from .models import StockLocation, StockItem, StockItemTracking +from .models import StockItemAttachment +from .models import StockItemTestResult class EditStockItemAttachmentForm(HelperForm): @@ -32,6 +34,22 @@ class EditStockItemAttachmentForm(HelperForm): ] +class EditStockItemTestResultForm(HelperForm): + """ + Form for creating / editing a StockItemTestResult object. + """ + + class Meta: + model = StockItemTestResult + fields = [ + 'stock_item', + 'test', + 'result', + 'value', + 'notes', + ] + + class EditStockLocationForm(HelperForm): """ Form for editing a StockLocation """ diff --git a/InvenTree/stock/templates/stock/item_tests.html b/InvenTree/stock/templates/stock/item_tests.html index a3e21962e1..6621637f1f 100644 --- a/InvenTree/stock/templates/stock/item_tests.html +++ b/InvenTree/stock/templates/stock/item_tests.html @@ -12,7 +12,9 @@
- +
+ +
@@ -36,4 +38,39 @@ loadStockTestResultsTable( } ); +function reloadTable() { + $("#test-result-table").bootstrapTable("refresh"); +} + +$("#add-test-result").click(function() { + launchModalForm( + "{% url 'stock-item-test-create' %}", { + data: { + stock_item: {{ item.id }}, + }, + success: reloadTable, + } + ); +}); + +$("#test-result-table").on('click', '.button-test-edit', function() { + var button = $(this); + + var url = `/stock/item/test/${button.attr('pk')}/edit/`; + + launchModalForm(url, { + success: reloadTable, + }); +}); + +$("#test-result-table").on('click', '.button-test-delete', function() { + var button = $(this); + + var url = `/stock/item/test/${button.attr('pk')}/delete/`; + + launchModalForm(url, { + success: reloadTable, + }); +}); + {% endblock %} \ No newline at end of file diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index f0b3091b84..ce99976f8b 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -52,12 +52,20 @@ stock_urls = [ url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'), + # URLs for StockItem attachments url(r'^item/attachment/', include([ url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'), url(r'^(?P\d+)/edit/', views.StockItemAttachmentEdit.as_view(), name='stock-item-attachment-edit'), url(r'^(?P\d+)/delete/', views.StockItemAttachmentDelete.as_view(), name='stock-item-attachment-delete'), ])), + # URLs for StockItem tests + url(r'^item/test/', include([ + url(r'^new/', views.StockItemTestResultCreate.as_view(), name='stock-item-test-create'), + url(r'^(?P\d+)/edit/', views.StockItemTestResultEdit.as_view(), name='stock-item-test-edit'), + url(r'^(?P\d+)/delete/', views.StockItemTestResultDelete.as_view(), name='stock-item-test-delete'), + ])), + url(r'^track/', include(stock_tracking_urls)), url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index dc70cc6bfb..84b5799898 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -26,7 +26,7 @@ from datetime import datetime from company.models import Company, SupplierPart from part.models import Part -from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment +from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult from .admin import StockItemResource @@ -38,6 +38,7 @@ from .forms import TrackingEntryForm from .forms import SerializeStockForm from .forms import ExportOptionsForm from .forms import EditStockItemAttachmentForm +from .forms import EditStockItemTestResultForm class StockIndex(ListView): @@ -228,6 +229,68 @@ class StockItemAttachmentDelete(AjaxDeleteView): } +class StockItemTestResultCreate(AjaxCreateView): + """ + View for adding a new StockItemTestResult + """ + + model = StockItemTestResult + form_class = EditStockItemTestResultForm + ajax_form_title = _("Add Test Result") + + def post_save(self, **kwargs): + """ Record the user that uploaded the test result """ + + self.object.user = self.request.user + self.object.save() + + def get_initial(self): + + initials = super().get_initial() + + try: + stock_id = self.request.GET.get('stock_item', None) + initials['stock_item'] = StockItem.objects.get(pk=stock_id) + except (ValueError, StockItem.DoesNotExist): + pass + + return initials + + def get_form(self): + + form = super().get_form() + form.fields['stock_item'].widget = HiddenInput() + + return form + + +class StockItemTestResultEdit(AjaxUpdateView): + """ + View for editing a StockItemTestResult + """ + + model = StockItemTestResult + form_class = EditStockItemTestResultForm + ajax_form_title = _("Edit Test Result") + + def get_form(self): + + form = super().get_form() + + form.fields['stock_item'].widget = HiddenInput() + + return form + +class StockItemTestResultDelete(AjaxDeleteView): + """ + View for deleting a StockItemTestResult + """ + + model = StockItemTestResult + ajax_form_title = _("Delete Test Result") + context_object_name = "result" + + class StockExportOptions(AjaxView): """ Form for selecting StockExport options """ diff --git a/InvenTree/templates/attachment_table.html b/InvenTree/templates/attachment_table.html index 71664a3ccc..32407cbc5b 100644 --- a/InvenTree/templates/attachment_table.html +++ b/InvenTree/templates/attachment_table.html @@ -28,7 +28,7 @@