diff --git a/.coveragerc b/.coveragerc index e953c6c86c..948d835f41 100644 --- a/.coveragerc +++ b/.coveragerc @@ -4,6 +4,8 @@ omit = # Do not run coverage on migration files */migrations/* InvenTree/manage.py + InvenTree/keygen.py Inventree/InvenTree/middleware.py Inventree/InvenTree/utils.py - Inventree/InvenTree/wsgi.py \ No newline at end of file + Inventree/InvenTree/wsgi.py + InvenTree/users/apps.py \ No newline at end of file diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 02f8159c6e..ddb4e35fee 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -6,7 +6,6 @@ import io import json import os.path from PIL import Image -import requests from wsgiref.util import FileWrapper from django.http import StreamingHttpResponse @@ -30,67 +29,8 @@ def TestIfImageURL(url): '.jpg', '.jpeg', '.png', '.bmp', '.tif', '.tiff', - '.webp', + '.webp', '.gif', ] - - -def DownloadExternalFile(url, **kwargs): - """ Attempt to download an external file - - Args: - url - External URL - - """ - - result = { - 'status': False, - 'url': url, - 'file': None, - 'status_code': 200, - } - - headers = {'User-Agent': 'Mozilla/5.0'} - max_size = kwargs.get('max_size', 1048576) # 1MB default limit - - # Get the HEAD for the file - try: - head = requests.head(url, stream=True, headers=headers) - except: - result['error'] = 'Error retrieving HEAD data' - return result - - if not head.status_code == 200: - result['error'] = 'Incorrect HEAD status code' - result['status_code'] = head.status_code - return result - - try: - filesize = int(head.headers['Content-Length']) - except ValueError: - result['error'] = 'Could not decode filesize' - result['extra'] = head.headers['Content-Length'] - return result - - if filesize > max_size: - result['error'] = 'File size too large ({s})'.format(s=filesize) - return result - - # All checks have passed - download the file - - try: - request = requests.get(url, stream=True, headers=headers) - except: - result['error'] = 'Error retriving GET data' - return result - - try: - dl_file = io.StringIO(request.text) - result['status'] = True - result['file'] = dl_file - return result - except: - result['error'] = 'Could not convert downloaded data to file' - return result def str2bool(text, test=True): diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py new file mode 100644 index 0000000000..ccdf2e2d43 --- /dev/null +++ b/InvenTree/InvenTree/tests.py @@ -0,0 +1,102 @@ +from django.test import TestCase +import django.core.exceptions as django_exceptions + +from .validators import validate_overage, validate_part_name +from . import helpers + + +class ValidatorTest(TestCase): + + """ Simple tests for custom field validators """ + + def test_part_name(self): + """ Test part name validator """ + + validate_part_name('hello world') + + with self.assertRaises(django_exceptions.ValidationError): + validate_part_name('This | name is not } valid') + + def test_overage(self): + """ Test overage validator """ + + validate_overage("100%") + validate_overage("10") + validate_overage("45.2 %") + + with self.assertRaises(django_exceptions.ValidationError): + validate_overage("-1") + + with self.assertRaises(django_exceptions.ValidationError): + validate_overage("-2.04 %") + + with self.assertRaises(django_exceptions.ValidationError): + validate_overage("105%") + + with self.assertRaises(django_exceptions.ValidationError): + validate_overage("xxx %") + + with self.assertRaises(django_exceptions.ValidationError): + validate_overage("aaaa") + + +class TestHelpers(TestCase): + """ Tests for InvenTree helper functions """ + + def test_image_url(self): + """ Test if a filename looks like an image """ + + for name in ['ape.png', 'bat.GiF', 'apple.WeBP', 'BiTMap.Bmp']: + self.assertTrue(helpers.TestIfImageURL(name)) + + for name in ['no.doc', 'nah.pdf', 'whatpng']: + self.assertFalse(helpers.TestIfImageURL(name)) + + def test_str2bool(self): + """ Test string to boolean conversion """ + + for s in ['yes', 'Y', 'ok', '1', 'OK', 'Ok', 'tRuE', 'oN']: + self.assertTrue(helpers.str2bool(s)) + self.assertFalse(helpers.str2bool(s, test=False)) + + for s in ['nO', '0', 'none', 'noNE', None, False, 'falSe', 'off']: + self.assertFalse(helpers.str2bool(s)) + self.assertTrue(helpers.str2bool(s, test=False)) + + for s in ['wombat', '', 'xxxx']: + self.assertFalse(helpers.str2bool(s)) + self.assertFalse(helpers.str2bool(s, test=False)) + + +class TestQuoteWrap(TestCase): + """ Tests for string wrapping """ + + def test_single(self): + + self.assertEqual(helpers.WrapWithQuotes('hello'), '"hello"') + self.assertEqual(helpers.WrapWithQuotes('hello"'), '"hello"') + + +class TestMakeBarcoede(TestCase): + """ Tests for barcode string creation """ + + def test_barcode(self): + + data = { + 'animal': 'cat', + 'legs': 3, + 'noise': 'purr' + } + + bc = helpers.MakeBarcode("part", 3, "www.google.com", data) + + self.assertIn('animal', bc) + self.assertIn('tool', bc) + self.assertIn('"tool": "InvenTree"', bc) + + +class TestDownloadFile(TestCase): + + def test_download(self): + helpers.DownloadFile("hello world", "out.txt") + helpers.DownloadFile(bytes("hello world".encode("utf8")), "out.bin") diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 28c7ecfcd3..2825731efc 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -110,7 +110,7 @@ class Build(models.Model): self.completion_date = datetime.now().date() self.completed_by = user - self.status = self.CANCELLED + self.status = BuildStatus.CANCELLED self.save() def getAutoAllocations(self): diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py index 1030a4e198..36e8223a9b 100644 --- a/InvenTree/build/tests.py +++ b/InvenTree/build/tests.py @@ -14,6 +14,7 @@ class BuildTestSimple(TestCase): def setUp(self): part = Part.objects.create(name='Test part', description='Simple description') + Build.objects.create(part=part, batch='B1', status=BuildStatus.PENDING, @@ -35,6 +36,8 @@ class BuildTestSimple(TestCase): self.assertEqual(b.batch, 'B2') self.assertEqual(b.quantity, 21) + self.assertEqual(str(b), 'Build 21 x Test part - Simple description') + def test_url(self): b1 = Build.objects.get(pk=1) self.assertEqual(b1.get_absolute_url(), '/build/1/') @@ -46,6 +49,8 @@ class BuildTestSimple(TestCase): self.assertEqual(b1.is_complete, False) self.assertEqual(b2.is_complete, True) + self.assertEqual(b2.status, BuildStatus.COMPLETE) + def test_is_active(self): b1 = Build.objects.get(pk=1) b2 = Build.objects.get(pk=2) @@ -56,3 +61,14 @@ class BuildTestSimple(TestCase): def test_required_parts(self): # TODO - Generate BOM for test part pass + + def cancel_build(self): + """ Test build cancellation function """ + + build = Build.objects.get(id=1) + + self.assertEqual(build.status, BuildStatus.PENDING) + + build.cancelBuild() + + self.assertEqual(build.status, BuildStatus.CANCELLED) diff --git a/InvenTree/company/fixtures/company.yaml b/InvenTree/company/fixtures/company.yaml index 7f95b6ec26..e83d886812 100644 --- a/InvenTree/company/fixtures/company.yaml +++ b/InvenTree/company/fixtures/company.yaml @@ -1,14 +1,17 @@ # Sample company data - model: company.company + pk: 1 fields: name: ACME description: A Cool Military Enterprise - model: company.company + pk: 2 fields: name: Appel Computers description: Think more differenter - model: company.company + pk: 3 fields: name: Zerg Corp description: We eat the competition \ No newline at end of file diff --git a/InvenTree/company/fixtures/supplier_part.yaml b/InvenTree/company/fixtures/supplier_part.yaml index f0076974d5..b9649704e1 100644 --- a/InvenTree/company/fixtures/supplier_part.yaml +++ b/InvenTree/company/fixtures/supplier_part.yaml @@ -15,6 +15,22 @@ supplier: 1 SKU: 'ACME0002' +# Widget purchaseable from ACME +- model: company.supplierpart + pk: 100 + fields: + part: 25 + supplier: 1 + SKU: 'ACME-WIDGET' + +# Widget purchaseable from Zerg +- model: company.supplierpart + pk: 101 + fields: + part: 25 + supplier: 2 + SKU: 'ZERG-WIDGET' + # M2x4 LPHS from Zerg Corp - model: company.supplierpart pk: 3 diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 68e9cbaf9b..9d09557f17 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -135,6 +135,10 @@ class Company(models.Model): """ Return purchase orders which are 'outstanding' """ return self.purchase_orders.filter(status__in=OrderStatus.OPEN) + def pending_purchase_orders(self): + """ Return purchase orders which are PENDING (not yet issued) """ + return self.purchase_orders.filter(status=OrderStatus.PENDING) + def closed_purchase_orders(self): """ Return purchase orders which are not 'outstanding' diff --git a/InvenTree/company/tests.py b/InvenTree/company/tests.py index b100710fc4..b4baec2976 100644 --- a/InvenTree/company/tests.py +++ b/InvenTree/company/tests.py @@ -56,10 +56,10 @@ class CompanySimpleTest(TestCase): zerg = Company.objects.get(pk=3) self.assertTrue(acme.has_parts) - self.assertEqual(acme.part_count, 2) + self.assertEqual(acme.part_count, 3) self.assertTrue(appel.has_parts) - self.assertEqual(appel.part_count, 1) + self.assertEqual(appel.part_count, 2) self.assertTrue(zerg.has_parts) self.assertEqual(zerg.part_count, 1) diff --git a/InvenTree/order/__init__.py b/InvenTree/order/__init__.py index e69de29bb2..896e9facd5 100644 --- a/InvenTree/order/__init__.py +++ b/InvenTree/order/__init__.py @@ -0,0 +1,3 @@ +""" +The Order module is responsible for managing Orders +""" diff --git a/InvenTree/order/fixtures/order.yaml b/InvenTree/order/fixtures/order.yaml new file mode 100644 index 0000000000..6fab8a705d --- /dev/null +++ b/InvenTree/order/fixtures/order.yaml @@ -0,0 +1,44 @@ +# PurchaseOrder and PurchaseOrderLineItem objects for testing + +# Ordering some screws from ACME +- model: order.purchaseorder + pk: 1 + fields: + reference: 0001 + description: "Ordering some screws" + supplier: 1 + +# Ordering some screws from Zerg Corp +- model: order.purchaseorder + pk: 2 + fields: + reference: 0002 + description: "Ordering some more screws" + supplier: 3 + +# Add some line items against PO 0001 + +# 100 x ACME0001 (M2x4 LPHS) +- model: order.purchaseorderlineitem + pk: 1 + fields: + order: 1 + part: 1 + quantity: 100 + +# 250 x ACME0002 (M2x4 LPHS) +# Partially received (50) +- model: order.purchaseorderlineitem + fields: + order: 1 + part: 2 + quantity: 250 + received: 50 + +# 100 x ZERGLPHS (M2x4 LPHS) +- model: order.purchaseorderlineitem + fields: + order: 2 + part: 3 + quantity: 100 + diff --git a/InvenTree/order/migrations/0011_auto_20190615_1928.py b/InvenTree/order/migrations/0011_auto_20190615_1928.py new file mode 100644 index 0000000000..cf6f1f61dc --- /dev/null +++ b/InvenTree/order/migrations/0011_auto_20190615_1928.py @@ -0,0 +1,26 @@ +# Generated by Django 2.2.2 on 2019-06-15 09:28 + +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), + ('order', '0010_purchaseorderlineitem_notes'), + ] + + operations = [ + migrations.AddField( + model_name='purchaseorder', + name='complete_date', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='purchaseorder', + name='received_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/InvenTree/order/migrations/0012_auto_20190617_1943.py b/InvenTree/order/migrations/0012_auto_20190617_1943.py new file mode 100644 index 0000000000..b47d73d134 --- /dev/null +++ b/InvenTree/order/migrations/0012_auto_20190617_1943.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.2 on 2019-06-17 09:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0011_auto_20190615_1928'), + ] + + operations = [ + migrations.AlterField( + model_name='purchaseorder', + name='creation_date', + field=models.DateField(blank=True, null=True), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index e2a0efc36d..ad8a4c0918 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -4,7 +4,7 @@ Order model definitions # -*- coding: utf-8 -*- -from django.db import models +from django.db import models, transaction from django.core.validators import MinValueValidator from django.core.exceptions import ValidationError from django.contrib.auth.models import User @@ -14,6 +14,7 @@ from django.utils.translation import ugettext as _ import tablib from datetime import datetime +from stock.models import StockItem from company.models import Company, SupplierPart from InvenTree.status_codes import OrderStatus @@ -33,6 +34,7 @@ class Order(models.Model): creation_date: Automatic date of order creation created_by: User who created this order (automatically captured) issue_date: Date the order was issued + complete_date: Date the order was completed """ @@ -48,6 +50,12 @@ class Order(models.Model): return " ".join(el) + def save(self, *args, **kwargs): + if not self.creation_date: + self.creation_date = datetime.now().date() + + super().save(*args, **kwargs) + class Meta: abstract = True @@ -57,7 +65,7 @@ class Order(models.Model): URL = models.URLField(blank=True, help_text=_('Link to external page')) - creation_date = models.DateField(auto_now=True, editable=False) + creation_date = models.DateField(blank=True, null=True) status = models.PositiveIntegerField(default=OrderStatus.PENDING, choices=OrderStatus.items(), help_text='Order status') @@ -70,6 +78,8 @@ class Order(models.Model): issue_date = models.DateField(blank=True, null=True) + complete_date = models.DateField(blank=True, null=True) + notes = models.TextField(blank=True, help_text=_('Order notes')) def place_order(self): @@ -80,13 +90,21 @@ class Order(models.Model): self.issue_date = datetime.now().date() self.save() + def complete_order(self): + """ Marks the order as COMPLETE. Order must be currently PLACED. """ + + if self.status == OrderStatus.PLACED: + self.status = OrderStatus.COMPLETE + self.complete_date = datetime.now().date() + self.save() + class PurchaseOrder(Order): """ A PurchaseOrder represents goods shipped inwards from an external supplier. Attributes: supplier: Reference to the company supplying the goods in the order - + received_by: User that received the goods """ ORDER_PREFIX = "PO" @@ -100,6 +118,13 @@ class PurchaseOrder(Order): help_text=_('Company') ) + received_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + blank=True, null=True, + related_name='+' + ) + def export_to_file(self, **kwargs): """ Export order information to external file """ @@ -151,6 +176,7 @@ class PurchaseOrder(Order): def get_absolute_url(self): return reverse('purchase-order-detail', kwargs={'pk': self.id}) + @transaction.atomic def add_line_item(self, supplier_part, quantity, group=True, reference=''): """ Add a new line item to this purchase order. This function will check that: @@ -195,6 +221,54 @@ class PurchaseOrder(Order): line.save() + def pending_line_items(self): + """ Return a list of pending line items for this order. + Any line item where 'received' < 'quantity' will be returned. + """ + + return [line for line in self.lines.all() if line.quantity > line.received] + + @transaction.atomic + def receive_line_item(self, line, location, quantity, user): + """ Receive a line item (or partial line item) against this PO + """ + + if not self.status == OrderStatus.PLACED: + raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")}) + + try: + quantity = int(quantity) + if quantity <= 0: + raise ValidationError({"quantity": _("Quantity must be greater than zero")}) + except ValueError: + raise ValidationError({"quantity": _("Invalid quantity provided")}) + + # Create a new stock item + if line.part: + stock = StockItem( + part=line.part.part, + location=location, + quantity=quantity, + purchase_order=self) + + stock.save() + + # Add a new transaction note to the newly created stock item + stock.addTransactionNote("Received items", user, "Received {q} items against order '{po}'".format( + q=quantity, + po=str(self)) + ) + + # Update the number of parts received against the particular line item + line.received += quantity + line.save() + + # Has this order been completed? + if len(self.pending_line_items()) == 0: + + self.received_by = user + self.complete_order() # This will save the model + class OrderLineItem(models.Model): """ Abstract model for an order line item @@ -251,3 +325,8 @@ class PurchaseOrderLineItem(OrderLineItem): ) received = models.PositiveIntegerField(default=0, help_text=_('Number of items received')) + + def remaining(self): + """ Calculate the number of items remaining to be received """ + r = self.quantity - self.received + return max(r, 0) diff --git a/InvenTree/order/templates/order/order_wizard/select_pos.html b/InvenTree/order/templates/order/order_wizard/select_pos.html index f97ba95ee2..4ae0a89c95 100644 --- a/InvenTree/order/templates/order/order_wizard/select_pos.html +++ b/InvenTree/order/templates/order/order_wizard/select_pos.html @@ -53,7 +53,7 @@ id='id-purchase-order-{{ supplier.id }}' name='purchase-order-{{ supplier.id }}'> - {% for order in supplier.outstanding_purchase_orders %} + {% for order in supplier.pending_purchase_orders %} diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index 185eba4cad..68370c9c22 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -41,11 +41,7 @@ InvenTree | {{ order }}
Line | -Part | -Order Code | -Reference | -Quantity | +Line | +Part | +Description | +Order Code | +Reference | +Quantity | {% if not order.status == OrderStatus.PENDING %} -Received | +Received | {% endif %} -Note | +Note | {% if order.status == OrderStatus.PENDING %} -+ | {% endif %} |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -98,9 +106,10 @@ InvenTree | {{ order }} {% include "hover_image.html" with image=line.part.part.image hover=True %} {{ line.part.part.full_name }} | +{{ line.part.part.description }} | {{ line.part.SKU }} | {% else %} -Warning: Part has been deleted. | +Warning: Part has been deleted. | {% endif %}{{ line.reference }} | {{ line.quantity }} | @@ -124,6 +133,7 @@ InvenTree | {{ order }} {% endif %}