diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index f0dd5f3d43..ad4b810e32 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -7,18 +7,90 @@ 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 PrependedText, AppendedText, 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 + field_prefix = {} + field_suffix = {} + field_placeholder = {} + def __init__(self, *args, **kwargs): super(forms.ModelForm, self).__init__(*args, **kwargs) self.helper = FormHelper() 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.rebuild_layout() + + def rebuild_layout(self): + + layouts = [] + + for field in self.fields: + 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 = r"".format(fa=prefix) + + if suffix and suffix.startswith('fa-'): + suffix = r"".format(fa=suffix) + + if prefix and suffix: + layouts.append( + Field( + PrependedAppendedText( + field, + prepended_text=prefix, + appended_text=suffix, + placeholder=placeholder + ) + ) + ) + + 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, placeholder=placeholder)) + + self.helper.layout = Layout(*layouts) + class DeleteForm(forms.Form): """ Generic deletion form which provides simple user confirmation 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/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, 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 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/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/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 """ 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/build/forms.py b/InvenTree/build/forms.py index d9f497da4a..f60c257cef 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -46,12 +46,23 @@ 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', + 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')) diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 54094632c1..23043d077f 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -53,6 +53,22 @@ 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() + + 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'), blank=False, 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/build/views.py b/InvenTree/build/views.py index 6f651a7628..2124207c7d 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -196,6 +196,15 @@ class BuildComplete(AjaxUpdateView): if not build.part.trackable: form.fields.pop('serial_numbers') + else: + if build.quantity == 1: + text = _('Next available serial number is') + else: + text = _('Next available serial numbers are') + + form.field_placeholder['serial_numbers'] = text + " " + build.part.getSerialNumberString(build.quantity) + + form.rebuild_layout() return form @@ -208,6 +217,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) @@ -282,7 +292,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/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/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 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 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/forms.py b/InvenTree/order/forms.py index 9991fe6670..ca61bd77be 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -88,6 +88,19 @@ class ReceivePurchaseOrderForm(HelperForm): class EditPurchaseOrderForm(HelperForm): """ Form for editing a PurchaseOrder object """ + def __init__(self, *args, **kwargs): + + self.field_prefix = { + 'reference': 'PO', + 'link': 'fa-link', + } + + self.field_placeholder = { + 'reference': _('Enter purchase order number'), + } + + super().__init__(*args, **kwargs) + class Meta: model = PurchaseOrder fields = [ @@ -102,6 +115,19 @@ class EditPurchaseOrderForm(HelperForm): class EditSalesOrderForm(HelperForm): """ Form for editing a SalesOrder object """ + def __init__(self, *args, **kwargs): + + self.field_prefix = { + 'reference': 'SO', + 'link': 'fa-link', + } + + self.field_placeholder = { + 'reference': _('Enter sales order number'), + } + + super().__init__(*args, **kwargs) + class Meta: model = SalesOrder fields = [ 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/models.py b/InvenTree/order/models.py index 56da6c783d..2ebc6a6793 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 @@ -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 = [] 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/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/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/tests.py b/InvenTree/order/tests.py index ca24b9586d..7b118dc60e 100644 --- a/InvenTree/order/tests.py +++ b/InvenTree/order/tests.py @@ -31,11 +31,17 @@ 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): + + 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 """ diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 370c629cac..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__) @@ -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 @@ -284,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) @@ -314,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) diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index 117763be84..035049fe81 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,65 @@ 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 + +# 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/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/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/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..7639b9c25b 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 @@ -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 @@ -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): """ @@ -262,6 +266,66 @@ class Part(models.Model): def __str__(self): return "{n} - {d}".format(n=self.full_name, d=self.description) + def checkIfSerialNumberExists(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 getHighestSerialNumber(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 getNextSerialNumber(self): + """ + Return the next-available serial number for this Part. + """ + + n = self.getHighestSerialNumber() + + if n is None: + return 1 + 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. @@ -638,32 +702,40 @@ class Part(models.Model): 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/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/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 c7c3c014a1..622f0af547 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -52,6 +52,20 @@ 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) + 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) self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?") 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) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 1c654d8b3c..aad36cc3dc 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 @@ -536,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"}) @@ -654,11 +655,89 @@ class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin): queryset = StockItemAttachment.objects.all() serializer_class = StockItemAttachmentSerializer + filter_backends = [ + DjangoFilterBackend, + filters.OrderingFilter, + filters.SearchFilter, + ] + filter_fields = [ 'stock_item', ] +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', + ] + + def get_serializer(self, *args, **kwargs): + try: + kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False)) + 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) + + 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. @@ -769,6 +848,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/fixtures/stock.yaml b/InvenTree/stock/fixtures/stock.yaml index ebc207f29c..156aa8db53 100644 --- a/InvenTree/stock/fixtures/stock.yaml +++ b/InvenTree/stock/fixtures/stock.yaml @@ -68,4 +68,172 @@ level: 0 tree_id: 0 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 + 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/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/forms.py b/InvenTree/stock/forms.py index a4578440cb..0d543b1315 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -13,7 +13,11 @@ from mptt.fields import TreeNodeChoiceField from InvenTree.helpers import GetExportFormats from InvenTree.forms import HelperForm -from .models import StockLocation, StockItem, StockItemTracking, StockItemAttachment +from InvenTree.fields import RoundingDecimalFormField + +from .models import StockLocation, StockItem, StockItemTracking +from .models import StockItemAttachment +from .models import StockItemTestResult class EditStockItemAttachmentForm(HelperForm): @@ -30,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 """ @@ -47,6 +67,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 = [ @@ -69,6 +98,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(): @@ -79,7 +109,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 +118,18 @@ 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 + item = kwargs.pop('item', None) + + if item: + self.field_placeholder['serial_numbers'] = item.part.getSerialNumberString(item.quantity) + + super().__init__(*args, **kwargs) + class Meta: model = StockItem 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), + ), + ] 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/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 ecfbf751c9..a73c7e0a76 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -142,9 +142,21 @@ 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. + """ + + self.validate_unique() + self.clean() + if not self.pk: + # StockItem has not yet been saved add_note = True else: + # StockItem has already been saved add_note = False user = kwargs.pop('user', None) @@ -172,59 +184,26 @@ 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 + 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). """ - 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) - # 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) - 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') - }) - except PartModels.Part.DoesNotExist: - pass + # Exclude myself from the search + if self.pk is not None: + stock = stock.exclude(pk=self.pk) + + if stock.exists(): + raise ValidationError({"serial": _("StockItem with this serial number already exists")}) def clean(self): """ Validate the StockItem object (separate to field validation) @@ -236,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", @@ -248,6 +229,24 @@ class StockItem(MPTTModel): 'status': 'Status cannot be marked as ASSIGNED_TO_OTHER_ITEM if the belongs_to field is not set', }) + 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: + 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: @@ -599,6 +598,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 +626,7 @@ class StockItem(MPTTModel): existing = [] for serial in serials: - if not StockItem.check_serial_number(self.part, serial): + if self.part.checkIfSerialNumberExists(serial): existing.append(serial) if len(existing) > 0: @@ -919,6 +921,51 @@ 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 + + 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 + + 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): @@ -991,3 +1038,86 @@ 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) + notes: Extra user notes related to the test (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'), + ) + + notes = models.CharField( + blank=True, max_length=500, + verbose_name=_('Notes'), + help_text=_("Test notes"), + ) + + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + blank=True, null=True + ) + + date = models.DateTimeField( + auto_now_add=True, + editable=False + ) diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 622f93e620..865a63a2c2 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 @@ -193,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) @@ -211,11 +212,55 @@ class StockItemAttachmentSerializer(InvenTreeModelSerializer): 'stock_item', 'attachment', 'comment', + 'upload_date', 'user', 'user_detail', ] +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 + + fields = [ + 'pk', + 'stock_item', + 'test', + 'result', + 'value', + 'attachment', + 'attachment_detail', + 'notes', + 'user', + 'user_detail', + 'date' + ] + + read_only_fields = [ + 'pk', + 'attachment', + 'user', + 'date', + ] + + class StockTrackingSerializer(InvenTreeModelSerializer): """ Serializer for StockItemTracking model """ 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 2d056afbcf..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 %} @@ -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 }}", { 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 new file mode 100644 index 0000000000..6621637f1f --- /dev/null +++ b/InvenTree/stock/templates/stock/item_tests.html @@ -0,0 +1,76 @@ +{% 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 }} + +loadStockTestResultsTable( + $("#test-result-table"), { + params: { + stock_item: {{ item.id }}, + user_detail: true, + attachment_detail: true, + }, + } +); + +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/templates/stock/tabs.html b/InvenTree/stock/templates/stock/tabs.html index fddd2f095f..5ec75ddc28 100644 --- a/InvenTree/stock/templates/stock/tabs.html +++ b/InvenTree/stock/templates/stock/tabs.html @@ -1,15 +1,20 @@ {% load i18n %} \ No newline at end of file diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index fe49547cee..cd598e8538 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,82 @@ 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 get_url(self): + return reverse('api-stock-test-result-list') + + def test_list(self): + + url = self.get_url() + 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_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 = self.get_url() + + 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) + + test = response.data[0] + self.assertEqual(test['value'], '150kPa') + self.assertEqual(test['user'], 1) diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index a866bdb880..8fce455a97 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): @@ -38,6 +39,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,17 +96,20 @@ 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(), 16) + self.assertEqual(self.drawer3.item_count, 16) 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 - 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): @@ -161,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)) @@ -327,3 +335,99 @@ 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.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.checkIfSerialNumberExists(20)) + self.assertTrue(chair.checkIfSerialNumberExists(21)) + self.assertTrue(chair.checkIfSerialNumberExists(22)) + + self.assertFalse(chair.checkIfSerialNumberExists(30)) + + self.assertEqual(chair.getNextSerialNumber(), 23) + + # Same operations on a sub-item + variant = Part.objects.get(pk=10003) + self.assertEqual(variant.getNextSerialNumber(), 23) + + # Create a new serial number + n = variant.getHighestSerialNumber() + + 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() + + +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) + + # 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()) diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 149c0dde2f..ce99976f8b 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'), @@ -51,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 e616be1f35..39bc7138b1 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,69 @@ 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 """ @@ -717,7 +781,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 @@ -757,6 +821,17 @@ class StockItemSerialize(AjaxUpdateView): ajax_form_title = _('Serialize Stock') 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): initials = super().get_initial().copy() @@ -764,6 +839,7 @@ class StockItemSerialize(AjaxUpdateView): item = self.get_object() initials['quantity'] = item.quantity + initials['serial_numbers'] = item.part.getSerialNumberString(item.quantity) initials['destination'] = item.location.pk return initials @@ -844,6 +920,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() @@ -851,6 +929,11 @@ 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) + + form.rebuild_layout() + # Hide the 'part' field (as a valid part is selected) form.fields['part'].widget = HiddenInput() @@ -873,6 +956,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 @@ -884,7 +968,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): @@ -917,6 +1001,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 @@ -954,6 +1039,8 @@ class StockItemCreate(AjaxCreateView): - Manage serial-number valdiation for tracked parts """ + part = None + form = self.get_form() data = {} @@ -965,12 +1052,22 @@ class StockItemCreate(AjaxCreateView): try: 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) + + form.rebuild_layout() + except (Part.DoesNotExist, ValueError, InvalidOperation): part = None quantity = 1 valid = False form.errors['quantity'] = [_('Invalid quantity')] + if quantity <= 0: + form.errors['quantity'] = [_('Quantity must be greater than zero')] + valid = False + if part is None: form.errors['part'] = [_('Invalid part selection')] else: @@ -988,7 +1085,7 @@ class StockItemCreate(AjaxCreateView): existing = [] for serial in serials: - if not StockItem.check_serial_number(part, serial): + if part.checkIfSerialNumberExists(serial): existing.append(serial) if len(existing) > 0: @@ -1003,24 +1100,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=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 @@ -1031,6 +1130,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) @@ -1038,20 +1155,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) diff --git a/InvenTree/templates/attachment_table.html b/InvenTree/templates/attachment_table.html index 090ae566f6..32407cbc5b 100644 --- a/InvenTree/templates/attachment_table.html +++ b/InvenTree/templates/attachment_table.html @@ -6,6 +6,7 @@
+
@@ -27,7 +28,7 @@ {% endfor %} -
\ No newline at end of file + +
\ No newline at end of file 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