diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 887b443544..84409226c2 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -94,6 +94,18 @@ def MakeBarcode(object_type, object_id, object_url, data={}): return json.dumps(data, sort_keys=True) +def GetExportFormats(): + """ Return a list of allowable file formats for exporting data """ + + return [ + 'csv', + 'tsv', + 'xls', + 'xlsx', + 'json', + ] + + def DownloadFile(data, filename, content_type='application/text'): """ Create a dynamic file for the user to download. diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 0c05a2a44b..38539d0118 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -11,10 +11,12 @@ from rest_framework.exceptions import ValidationError from django.db.models.signals import pre_delete from django.dispatch import receiver +from mptt.models import MPTTModel, TreeForeignKey + from .validators import validate_tree_name -class InvenTreeTree(models.Model): +class InvenTreeTree(MPTTModel): """ Provides an abstracted self-referencing tree model for data categories. - Each Category has one parent Category, which can be blank (for a top-level Category). @@ -30,6 +32,9 @@ class InvenTreeTree(models.Model): abstract = True unique_together = ('name', 'parent') + class MPTTMeta: + order_insertion_by = ['name'] + name = models.CharField( blank=False, max_length=100, @@ -43,11 +48,11 @@ class InvenTreeTree(models.Model): ) # When a category is deleted, graft the children onto its parent - parent = models.ForeignKey('self', - on_delete=models.DO_NOTHING, - blank=True, - null=True, - related_name='children') + parent = TreeForeignKey('self', + on_delete=models.DO_NOTHING, + blank=True, + null=True, + related_name='children') @property def item_count(self): @@ -60,59 +65,31 @@ class InvenTreeTree(models.Model): """ return 0 - def getUniqueParents(self, unique=None): + def getUniqueParents(self): """ Return a flat set of all parent items that exist above this node. If any parents are repeated (which would be very bad!), the process is halted """ - item = self + return self.get_ancestors() - # Prevent infinite regression - max_parents = 500 - - unique = set() - - while item.parent and max_parents > 0: - max_parents -= 1 - - unique.add(item.parent.id) - item = item.parent - - return unique - - def getUniqueChildren(self, unique=None, include_self=True): + def getUniqueChildren(self, include_self=True): """ Return a flat set of all child items that exist under this node. If any child items are repeated, the repetitions are omitted. """ - if unique is None: - unique = set() - - if self.id in unique: - return unique - - if include_self: - unique.add(self.id) - - # Some magic to get around the limitations of abstract models - contents = ContentType.objects.get_for_model(type(self)) - children = contents.get_all_objects_for_this_type(parent=self.id) - - for child in children: - child.getUniqueChildren(unique) - - return unique + return self.get_descendants(include_self=include_self) @property def has_children(self): """ True if there are any children under this item """ - return self.children.count() > 0 + return self.getUniqueChildren(include_self=False).count() > 0 def getAcceptableParents(self): """ Returns a list of acceptable parent items within this model Acceptable parents are ones which are not underneath this item. Setting the parent of an item to its own child results in recursion. """ + contents = ContentType.objects.get_for_model(type(self)) available = contents.get_all_objects_for_this_type() @@ -136,10 +113,7 @@ class InvenTreeTree(models.Model): List of category names from the top level to the parent of this category """ - if self.parent: - return self.parent.parentpath + [self.parent] - else: - return [] + return [a for a in self.get_ancestors()] @property def path(self): @@ -183,7 +157,7 @@ class InvenTreeTree(models.Model): pass # Ensure that the new parent is not already a child - if self.id in self.getUniqueChildren(include_self=False): + if self.pk is not None and self.id in self.getUniqueChildren(include_self=False): raise ValidationError("Category cannot set a child as parent") def __str__(self): diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 6c9d4a9b55..2f5572a5f8 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -58,9 +58,7 @@ cors_opt = CONFIG.get('cors', None) if cors_opt: CORS_ORIGIN_ALLOW_ALL = cors_opt.get('allow_all', False) - if CORS_ORIGIN_ALLOW_ALL: - eprint("Warning: CORS requests are allowed for any domain!") - else: + if not CORS_ORIGIN_ALLOW_ALL: CORS_ORIGIN_WHITELIST = cors_opt.get('whitelist', []) if DEBUG: @@ -100,6 +98,7 @@ INSTALLED_APPS = [ 'import_export', # Import / export tables to file 'django_cleanup', # Automatically delete orphaned MEDIA files 'qr_code', # Generate QR codes + 'mptt', # Modified Preorder Tree Traversal ] LOGGING = { diff --git a/InvenTree/InvenTree/static/script/inventree/bom.js b/InvenTree/InvenTree/static/script/inventree/bom.js index 1e66af5c7e..aa64a776f5 100644 --- a/InvenTree/InvenTree/static/script/inventree/bom.js +++ b/InvenTree/InvenTree/static/script/inventree/bom.js @@ -330,7 +330,9 @@ function loadBomTable(table, options) { }, { method: 'PATCH', - reloadOnSuccess: true + success: function() { + reloadBomTable(table); + } } ); }); diff --git a/InvenTree/InvenTree/static/script/inventree/stock.js b/InvenTree/InvenTree/static/script/inventree/stock.js index e4fa188598..ae07be1dd3 100644 --- a/InvenTree/InvenTree/static/script/inventree/stock.js +++ b/InvenTree/InvenTree/static/script/inventree/stock.js @@ -76,12 +76,14 @@ function loadStockTable(table, options) { } else if (field == 'quantity') { var stock = 0; + var items = 0; data.forEach(function(item) { stock += item.quantity; + items += 1; }); - return stock; + return stock + " (" + items + " items)"; } else if (field == 'batch') { var batches = []; diff --git a/InvenTree/InvenTree/status_codes.py b/InvenTree/InvenTree/status_codes.py index b7b50a58f7..f772943ef0 100644 --- a/InvenTree/InvenTree/status_codes.py +++ b/InvenTree/InvenTree/status_codes.py @@ -10,7 +10,7 @@ class StatusCode: @classmethod def label(cls, value): """ Return the status code label associated with the provided value """ - return cls.options.get(value, '') + return cls.options.get(value, value) class OrderStatus(StatusCode): diff --git a/InvenTree/company/templates/company/detail_stock.html b/InvenTree/company/templates/company/detail_stock.html index 95c7d1f6d1..43966810b8 100644 --- a/InvenTree/company/templates/company/detail_stock.html +++ b/InvenTree/company/templates/company/detail_stock.html @@ -27,4 +27,18 @@ ] }); + $("#stock-export").click(function() { + launchModalForm("{% url 'stock-export-options' %}", { + submit_text: "Export", + success: function(response) { + var url = "{% url 'stock-export' %}"; + + url += "?format=" + response.format; + url += "&supplier={{ company.id }}"; + + location.href = url; + }, + }); + }); + {% endblock %} \ No newline at end of file diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index ad8a4c0918..9408247fd6 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -247,6 +247,7 @@ class PurchaseOrder(Order): if line.part: stock = StockItem( part=line.part.part, + supplier_part=line.part, location=location, quantity=quantity, purchase_order=self) diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index 575dd4a74b..2ddbdc3052 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -25,6 +25,27 @@ InvenTree | {{ order }} {% if order.URL %} {{ order.URL }} {% endif %} +

+

+
+ + + {% if order.status == OrderStatus.PENDING and order.lines.count > 0 %} + + {% elif order.status == OrderStatus.PLACED %} + + {% endif %} +
+
+

@@ -65,13 +86,6 @@ InvenTree | {{ order }} {% if order.status == OrderStatus.PENDING %} {% endif %} - - {% if order.status == OrderStatus.PENDING and order.lines.count > 0 %} - - {% elif order.status == OrderStatus.PLACED %} - - {% endif %} -

Order Items

diff --git a/InvenTree/order/templates/order/purchase_orders.html b/InvenTree/order/templates/order/purchase_orders.html index 78649ca6de..0e93c6d900 100644 --- a/InvenTree/order/templates/order/purchase_orders.html +++ b/InvenTree/order/templates/order/purchase_orders.html @@ -27,7 +27,7 @@ InvenTree | Purchase Orders $("#po-create").click(function() { launchModalForm("{% url 'purchase-order-create' %}", { - reload: true, + follow: true, } ); }); diff --git a/InvenTree/part/fixtures/category.yaml b/InvenTree/part/fixtures/category.yaml index 2f46dcce65..ad40b6b1fd 100644 --- a/InvenTree/part/fixtures/category.yaml +++ b/InvenTree/part/fixtures/category.yaml @@ -6,7 +6,11 @@ name: Electronics description: Electronic components parent: null - default_location: 1 # Home + default_location: 1 + level: 0 + tree_id: 1 + lft: 1 + rght: 12 - model: part.partcategory pk: 2 @@ -15,6 +19,10 @@ description: Resistors parent: 1 default_location: null + level: 1 + tree_id: 1 + lft: 2 + rght: 3 - model: part.partcategory pk: 3 @@ -23,6 +31,10 @@ description: Capacitors parent: 1 default_location: null + level: 1 + tree_id: 1 + lft: 4 + rght: 5 - model: part.partcategory pk: 4 @@ -31,6 +43,10 @@ description: Integrated Circuits parent: 1 default_location: null + level: 1 + tree_id: 1 + lft: 6 + rght: 11 - model: part.partcategory pk: 5 @@ -39,6 +55,10 @@ description: Microcontrollers parent: 4 default_location: null + level: 2 + tree_id: 1 + lft: 7 + rght: 8 - model: part.partcategory pk: 6 @@ -47,6 +67,10 @@ description: Communication interfaces parent: 4 default_location: null + level: 2 + tree_id: 1 + lft: 9 + rght: 10 - model: part.partcategory pk: 7 @@ -54,6 +78,10 @@ name: Mechanical description: Mechanical componenets default_location: null + level: 0 + tree_id: 2 + lft: 1 + rght: 4 - model: part.partcategory pk: 8 @@ -62,3 +90,7 @@ description: Screws, bolts, etc parent: 7 default_location: 5 + level: 1 + tree_id: 2 + lft: 2 + rght: 3 diff --git a/InvenTree/part/fixtures/params.yaml b/InvenTree/part/fixtures/params.yaml new file mode 100644 index 0000000000..4827d0987b --- /dev/null +++ b/InvenTree/part/fixtures/params.yaml @@ -0,0 +1,32 @@ +# Create some PartParameter templtes + +- model: part.PartParameterTemplate + pk: 1 + fields: + name: Length + units: mm + +- model: part.PartParameterTemplate + pk: 2 + fields: + name: Width + units: mm + +- model: part.PartParameterTemplate + pk: 3 + fields: + name: Thickness + units: mm + +# And some parameters (requires part.yaml) +- model: part.PartParameter + fields: + part: 1 + template: 1 + data: 4 + +- model: part.PartParameter + fields: + part: 2 + template: 1 + data: 12 \ No newline at end of file diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index 77b96a3471..8f4963cbad 100644 --- a/InvenTree/part/fixtures/part.yaml +++ b/InvenTree/part/fixtures/part.yaml @@ -59,4 +59,8 @@ name: 'Bob' description: 'Can we build it?' assembly: true - purchaseable: false \ No newline at end of file + purchaseable: false + category: 7 + active: False + IPN: BOB + revision: A2 \ No newline at end of file diff --git a/InvenTree/part/migrations/0019_auto_20190908_0404.py b/InvenTree/part/migrations/0019_auto_20190908_0404.py new file mode 100644 index 0000000000..160bb929b9 --- /dev/null +++ b/InvenTree/part/migrations/0019_auto_20190908_0404.py @@ -0,0 +1,37 @@ +# Generated by Django 2.2.5 on 2019-09-08 04:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0018_auto_20190907_0941'), + ] + + operations = [ + migrations.AddField( + model_name='partcategory', + name='level', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='partcategory', + name='lft', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='partcategory', + name='rght', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='partcategory', + name='tree_id', + field=models.PositiveIntegerField(db_index=True, default=0, editable=False), + preserve_default=False, + ), + ] diff --git a/InvenTree/part/migrations/0020_auto_20190908_0404.py b/InvenTree/part/migrations/0020_auto_20190908_0404.py new file mode 100644 index 0000000000..4f27191099 --- /dev/null +++ b/InvenTree/part/migrations/0020_auto_20190908_0404.py @@ -0,0 +1,21 @@ +# Generated by Django 2.2.5 on 2019-09-08 04:04 + +from django.db import migrations +from part import models + + +def update_tree(apps, schema_editor): + # Update the PartCategory MPTT model + + models.PartCategory.objects.rebuild() + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0019_auto_20190908_0404'), + ] + + operations = [ + migrations.RunPython(update_tree) + ] diff --git a/InvenTree/part/migrations/0021_auto_20190908_0916.py b/InvenTree/part/migrations/0021_auto_20190908_0916.py new file mode 100644 index 0000000000..0427487246 --- /dev/null +++ b/InvenTree/part/migrations/0021_auto_20190908_0916.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.5 on 2019-09-08 09:16 + +from django.db import migrations +import django.db.models.deletion +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0020_auto_20190908_0404'), + ] + + operations = [ + migrations.AlterField( + model_name='part', + name='category', + field=mptt.fields.TreeForeignKey(blank=True, help_text='Part category', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='parts', to='part.PartCategory'), + ), + migrations.AlterField( + model_name='part', + name='default_location', + field=mptt.fields.TreeForeignKey(blank=True, help_text='Where is this item normally stored?', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_parts', to='stock.StockLocation'), + ), + migrations.AlterField( + model_name='partcategory', + name='default_location', + field=mptt.fields.TreeForeignKey(blank=True, help_text='Default location for parts in this category', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_categories', to='stock.StockLocation'), + ), + ] diff --git a/InvenTree/part/migrations/0022_auto_20190908_0918.py b/InvenTree/part/migrations/0022_auto_20190908_0918.py new file mode 100644 index 0000000000..48782ed545 --- /dev/null +++ b/InvenTree/part/migrations/0022_auto_20190908_0918.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.5 on 2019-09-08 09:18 + +from django.db import migrations +import django.db.models.deletion +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0021_auto_20190908_0916'), + ] + + operations = [ + migrations.AlterField( + model_name='partcategory', + name='parent', + field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='part.PartCategory'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 4590a062a8..f57da3cfdb 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -25,6 +25,8 @@ from django.contrib.auth.models import User from django.db.models.signals import pre_delete from django.dispatch import receiver +from mptt.models import TreeForeignKey + from datetime import datetime from fuzzywuzzy import fuzz import hashlib @@ -48,7 +50,7 @@ class PartCategory(InvenTreeTree): default_keywords: Default keywords for parts created in this category """ - default_location = models.ForeignKey( + default_location = TreeForeignKey( 'stock.StockLocation', related_name="default_categories", null=True, blank=True, on_delete=models.SET_NULL, @@ -64,21 +66,31 @@ class PartCategory(InvenTreeTree): verbose_name = "Part Category" verbose_name_plural = "Part Categories" + def get_parts(self, cascade=True): + """ Return a queryset for all parts under this category. + + args: + cascade - If True, also look under subcategories (default = True) + """ + + if cascade: + """ Select any parts which exist in this category or any child categories """ + query = Part.objects.filter(category__in=self.getUniqueChildren(include_self=True)) + else: + query = Part.objects.filter(category=self.pk) + + return query + @property def item_count(self): return self.partcount() - def partcount(self, cascade=True, active=True): + def partcount(self, cascade=True, active=False): """ Return the total part count under this category (including children of child categories) """ - cats = [self.id] - - if cascade: - cats += [cat for cat in self.getUniqueChildren()] - - query = Part.objects.filter(category__in=cats) + query = self.get_parts(cascade=cascade) if active: query = query.filter(active=True) @@ -88,7 +100,7 @@ class PartCategory(InvenTreeTree): @property def has_parts(self): """ True if there are any parts in this category """ - return self.parts.count() > 0 + return self.partcount() > 0 @receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log') @@ -253,17 +265,9 @@ class Part(models.Model): def set_category(self, category): - if not type(category) == PartCategory: - raise ValidationError({ - 'category': _('Invalid object supplied to part.set_category') - }) - - try: - # Already in this category! - if category == self.category: - return - except PartCategory.DoesNotExist: - pass + # Ignore if the category is already the same + if self.category == category: + return self.category = category self.save() @@ -340,10 +344,10 @@ class Part(models.Model): keywords = models.CharField(max_length=250, blank=True, help_text='Part keywords to improve visibility in search results') - category = models.ForeignKey(PartCategory, related_name='parts', - null=True, blank=True, - on_delete=models.DO_NOTHING, - help_text='Part category') + category = TreeForeignKey(PartCategory, related_name='parts', + null=True, blank=True, + on_delete=models.DO_NOTHING, + help_text='Part category') IPN = models.CharField(max_length=100, blank=True, help_text='Internal Part Number') @@ -353,10 +357,10 @@ class Part(models.Model): image = models.ImageField(upload_to=rename_part_image, max_length=255, null=True, blank=True) - default_location = models.ForeignKey('stock.StockLocation', on_delete=models.SET_NULL, - blank=True, null=True, - help_text='Where is this item normally stored?', - related_name='default_parts') + default_location = TreeForeignKey('stock.StockLocation', on_delete=models.SET_NULL, + blank=True, null=True, + help_text='Where is this item normally stored?', + related_name='default_parts') def get_default_location(self): """ Get the default location for a Part (may be None). @@ -370,13 +374,11 @@ class Part(models.Model): return self.default_location elif self.category: # Traverse up the category tree until we find a default location - cat = self.category + cats = self.category.get_ancestors(ascending=True, include_self=True) - while cat: + for cat in cats: if cat.default_location: return cat.default_location - else: - cat = cat.parent # Default case - no default category found return None @@ -1055,7 +1057,7 @@ class PartParameterTemplate(models.Model): super().validate_unique(exclude) try: - others = PartParameterTemplate.objects.exclude(id=self.id).filter(name__iexact=self.name) + others = PartParameterTemplate.objects.filter(name__iexact=self.name).exclude(pk=self.pk) if others.exists(): msg = _("Parameter template name must be unique") @@ -1063,11 +1065,6 @@ class PartParameterTemplate(models.Model): except PartParameterTemplate.DoesNotExist: pass - @property - def instance_count(self): - """ Return the number of instances of this Parameter Template """ - return self.instances.count() - name = models.CharField(max_length=100, help_text='Parameter Name', unique=True) units = models.CharField(max_length=25, help_text='Parameter Units', blank=True) @@ -1086,7 +1083,7 @@ class PartParameter(models.Model): def __str__(self): # String representation of a PartParameter (used in the admin interface) return "{part} : {param} = {data}{units}".format( - part=str(self.part), + part=str(self.part.full_name), param=str(self.template.name), data=str(self.data), units=str(self.template.units) @@ -1096,8 +1093,7 @@ class PartParameter(models.Model): # Prevent multiple instances of a parameter for a single part unique_together = ('part', 'template') - part = models.ForeignKey(Part, on_delete=models.CASCADE, - related_name='parameters', help_text='Parent Part') + part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='parameters', help_text='Parent Part') template = models.ForeignKey(PartParameterTemplate, on_delete=models.CASCADE, related_name='instances', help_text='Parameter Template') diff --git a/InvenTree/part/templates/part/stock.html b/InvenTree/part/templates/part/stock.html index a41d5fd32a..6050137b69 100644 --- a/InvenTree/part/templates/part/stock.html +++ b/InvenTree/part/templates/part/stock.html @@ -47,6 +47,21 @@ url: "{% url 'api-stock-list' %}", }); + $("#stock-export").click(function() { + launchModalForm("{% url 'stock-export-options' %}", { + submit_text: "Export", + success: function(response) { + var url = "{% url 'stock-export' %}"; + + url += "?format=" + response.format; + url += "&cascade=" + response.cascade; + url += "&part={{ part.id }}"; + + location.href = url; + }, + }); + }); + $('#item-create').click(function () { launchModalForm("{% url 'stock-item-create' %}", { reload: true, diff --git a/InvenTree/part/test_bom_item.py b/InvenTree/part/test_bom_item.py index 6c1d91810c..ec29dbaab3 100644 --- a/InvenTree/part/test_bom_item.py +++ b/InvenTree/part/test_bom_item.py @@ -19,7 +19,7 @@ class BomItemTest(TestCase): def test_str(self): b = BomItem.objects.get(id=1) - self.assertEqual(str(b), '10 x M2x4 LPHS to make Bob') + self.assertEqual(str(b), '10 x M2x4 LPHS to make BOB | Bob | A2') def test_has_bom(self): self.assertFalse(self.orphan.has_bom) diff --git a/InvenTree/part/test_category.py b/InvenTree/part/test_category.py index d9bb42d546..db95836107 100644 --- a/InvenTree/part/test_category.py +++ b/InvenTree/part/test_category.py @@ -48,7 +48,7 @@ class CategoryTest(TestCase): def test_unique_childs(self): """ Test the 'unique_children' functionality """ - childs = self.electronics.getUniqueChildren() + childs = [item.pk for item in self.electronics.getUniqueChildren()] self.assertIn(self.transceivers.id, childs) self.assertIn(self.ic.id, childs) @@ -58,7 +58,7 @@ class CategoryTest(TestCase): def test_unique_parents(self): """ Test the 'unique_parents' functionality """ - parents = self.transceivers.getUniqueParents() + parents = [item.pk for item in self.transceivers.getUniqueParents()] self.assertIn(self.electronics.id, parents) self.assertIn(self.ic.id, parents) @@ -87,6 +87,12 @@ 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.electronics.item_count, self.electronics.partcount()) + def test_delete(self): """ Test that category deletion moves the children properly """ diff --git a/InvenTree/part/test_param.py b/InvenTree/part/test_param.py new file mode 100644 index 0000000000..6876a2b5df --- /dev/null +++ b/InvenTree/part/test_param.py @@ -0,0 +1,42 @@ +# Tests for Part Parameters + +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.test import TestCase +import django.core.exceptions as django_exceptions + +from .models import PartParameter, PartParameterTemplate + + +class TestParams(TestCase): + + fixtures = [ + 'location', + 'category', + 'part', + 'params' + ] + + def test_str(self): + + t1 = PartParameterTemplate.objects.get(pk=1) + self.assertEquals(str(t1), 'Length (mm)') + + p1 = PartParameter.objects.get(pk=1) + self.assertEqual(str(p1), "M2x4 LPHS : Length = 4mm") + + def test_validate(self): + + n = PartParameterTemplate.objects.all().count() + + t1 = PartParameterTemplate(name='abcde', units='dd') + t1.save() + + self.assertEqual(n + 1, PartParameterTemplate.objects.all().count()) + + # Test that the case-insensitive name throws a ValidationError + with self.assertRaises(django_exceptions.ValidationError): + t3 = PartParameterTemplate(name='aBcde', units='dd') + t3.full_clean() + t3.save() diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index c9028cd406..84d9900aff 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -1,9 +1,14 @@ +# Tests for the Part model + +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + from django.test import TestCase import os from .models import Part -from .models import rename_part_image +from .models import rename_part_image, match_part_names from .templatetags import inventree_extras @@ -39,6 +44,10 @@ class PartTest(TestCase): self.C1 = Part.objects.get(name='C_22N_0805') + def test_str(self): + p = Part.objects.get(pk=100) + self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?") + def test_metadata(self): self.assertEqual(self.R1.name, 'R_2K2_0805') self.assertEqual(self.R1.get_absolute_url(), '/part/3/') @@ -70,5 +79,10 @@ class PartTest(TestCase): self.assertIn(self.R1.name, barcode) def test_copy(self): - self.R2.deepCopy(self.R1, image=True, bom=True) + + def test_match_names(self): + + matches = match_part_names('M2x5 LPHS') + + self.assertTrue(len(matches) > 0) diff --git a/InvenTree/stock/fixtures/location.yaml b/InvenTree/stock/fixtures/location.yaml index f34490af29..1d5d1300af 100644 --- a/InvenTree/stock/fixtures/location.yaml +++ b/InvenTree/stock/fixtures/location.yaml @@ -5,6 +5,10 @@ fields: name: 'Home' description: 'My house' + level: 0 + tree_id: 1 + lft: 1 + rght: 6 - model: stock.stocklocation pk: 2 @@ -12,6 +16,10 @@ name: 'Bathroom' description: 'Where I keep my bath' parent: 1 + level: 1 + tree_id: 1 + lft: 2 + rght: 3 - model: stock.stocklocation pk: 3 @@ -19,12 +27,20 @@ name: 'Dining Room' description: 'A table lives here' parent: 1 + level: 0 + tree_id: 1 + lft: 4 + rght: 5 - model: stock.stocklocation pk: 4 fields: name: 'Office' description: 'Place of work' + level: 0 + tree_id: 2 + lft: 1 + rght: 8 - model: stock.stocklocation pk: 5 @@ -32,6 +48,10 @@ name: 'Drawer_1' description: 'In my desk' parent: 4 + level: 0 + tree_id: 2 + lft: 2 + rght: 3 - model: stock.stocklocation pk: 6 @@ -39,10 +59,18 @@ name: 'Drawer_2' description: 'Also in my desk' parent: 4 + level: 0 + tree_id: 2 + lft: 4 + rght: 5 - model: stock.stocklocation pk: 7 fields: name: 'Drawer_3' description: 'Again, in my desk' - parent: 4 \ No newline at end of file + parent: 4 + level: 0 + tree_id: 2 + lft: 6 + rght: 7 \ No newline at end of file diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 6ad25ff279..3a34e0416d 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -9,6 +9,7 @@ from django import forms from django.forms.utils import ErrorDict from django.utils.translation import ugettext as _ +from InvenTree.helpers import GetExportFormats from InvenTree.forms import HelperForm from .models import StockLocation, StockItem, StockItemTracking @@ -96,6 +97,33 @@ class SerializeStockForm(forms.ModelForm): ] +class ExportOptionsForm(HelperForm): + """ Form for selecting stock export options """ + + file_format = forms.ChoiceField(label=_('File Format'), help_text=_('Select output file format')) + + include_sublocations = forms.BooleanField(required=False, initial=True, help_text=_("Include stock items in sub locations")) + + class Meta: + model = StockLocation + fields = [ + 'file_format', + 'include_sublocations', + ] + + def get_format_choices(self): + """ File format choices """ + + choices = [(x, x.upper()) for x in GetExportFormats()] + + return choices + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['file_format'].choices = self.get_format_choices() + + class AdjustStockForm(forms.ModelForm): """ Form for performing simple stock adjustments. diff --git a/InvenTree/stock/migrations/0011_auto_20190908_0404.py b/InvenTree/stock/migrations/0011_auto_20190908_0404.py new file mode 100644 index 0000000000..7fc85d0556 --- /dev/null +++ b/InvenTree/stock/migrations/0011_auto_20190908_0404.py @@ -0,0 +1,37 @@ +# Generated by Django 2.2.5 on 2019-09-08 04:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0010_stockitem_build'), + ] + + operations = [ + migrations.AddField( + model_name='stocklocation', + name='level', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='stocklocation', + name='lft', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='stocklocation', + name='rght', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='stocklocation', + name='tree_id', + field=models.PositiveIntegerField(db_index=True, default=0, editable=False), + preserve_default=False, + ), + ] diff --git a/InvenTree/stock/migrations/0012_auto_20190908_0405.py b/InvenTree/stock/migrations/0012_auto_20190908_0405.py new file mode 100644 index 0000000000..52071e5cd4 --- /dev/null +++ b/InvenTree/stock/migrations/0012_auto_20190908_0405.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2.5 on 2019-09-08 04:05 + +from django.db import migrations + +from stock import models + + +def update_tree(apps, schema_editor): + # Update the StockLocation MPTT model + + models.StockLocation.objects.rebuild() + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0011_auto_20190908_0404'), + ] + + operations = [ + migrations.RunPython(update_tree) + ] diff --git a/InvenTree/stock/migrations/0013_auto_20190908_0916.py b/InvenTree/stock/migrations/0013_auto_20190908_0916.py new file mode 100644 index 0000000000..897d4298c2 --- /dev/null +++ b/InvenTree/stock/migrations/0013_auto_20190908_0916.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.5 on 2019-09-08 09:16 + +from django.db import migrations +import django.db.models.deletion +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0012_auto_20190908_0405'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitem', + name='location', + field=mptt.fields.TreeForeignKey(blank=True, help_text='Where is this stock item located?', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='stock_items', to='stock.StockLocation'), + ), + ] diff --git a/InvenTree/stock/migrations/0014_auto_20190908_0918.py b/InvenTree/stock/migrations/0014_auto_20190908_0918.py new file mode 100644 index 0000000000..b6f7411e10 --- /dev/null +++ b/InvenTree/stock/migrations/0014_auto_20190908_0918.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.5 on 2019-09-08 09:18 + +from django.db import migrations +import django.db.models.deletion +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0013_auto_20190908_0916'), + ] + + operations = [ + migrations.AlterField( + model_name='stocklocation', + name='parent', + field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='stock.StockLocation'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 78cef49371..e9220cabcb 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -16,6 +16,8 @@ from django.contrib.auth.models import User from django.db.models.signals import pre_delete from django.dispatch import receiver +from mptt.models import TreeForeignKey + from datetime import datetime from InvenTree import helpers @@ -34,9 +36,6 @@ class StockLocation(InvenTreeTree): def get_absolute_url(self): return reverse('stock-location-detail', kwargs={'pk': self.id}) - def has_items(self): - return self.stock_items.count() > 0 - def format_barcode(self): """ Return a JSON string for formatting a barcode for this StockLocation object """ @@ -49,16 +48,33 @@ class StockLocation(InvenTreeTree): } ) + def get_stock_items(self, cascade=True): + """ Return a queryset for all stock items under this category. + + Args: + cascade: If True, also look under sublocations (default = True) + """ + + if cascade: + query = StockItem.objects.filter(location__in=self.getUniqueChildren(include_self=True)) + else: + query = StockItem.objects.filter(location=self.pk) + + return query + def stock_item_count(self, cascade=True): """ Return the number of StockItem objects which live in or under this category """ - if cascade: - query = StockItem.objects.filter(location__in=self.getUniqueChildren()) - else: - query = StockItem.objects.filter(location=self) + return self.get_stock_items(cascade).count() - return query.count() + def has_items(self, cascade=True): + """ Return True if there are StockItems existing in this category. + + Args: + cascade: If True, also search an sublocations (default = True) + """ + return self.stock_item_count(cascade) > 0 @property def item_count(self): @@ -277,9 +293,9 @@ class StockItem(models.Model): supplier_part = models.ForeignKey('company.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL, help_text='Select a matching supplier part for this stock item') - location = models.ForeignKey(StockLocation, on_delete=models.DO_NOTHING, - related_name='stock_items', blank=True, null=True, - help_text='Where is this stock item located?') + location = TreeForeignKey(StockLocation, on_delete=models.DO_NOTHING, + related_name='stock_items', blank=True, null=True, + help_text='Where is this stock item located?') belongs_to = models.ForeignKey('self', on_delete=models.DO_NOTHING, related_name='owned_parts', blank=True, null=True, diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 30adba9336..6a0d05cfcc 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -67,6 +67,24 @@ sessionStorage.removeItem('inventree-show-part-locations'); }); + $("#stock-export").click(function() { + launchModalForm("{% url 'stock-export-options' %}", { + submit_text: "Export", + success: function(response) { + var url = "{% url 'stock-export' %}"; + + url += "?format=" + response.format; + url += "&cascade=" + response.cascade; + + {% if location %} + url += "&location={{ location.id }}"; + {% endif %} + + location.href = url; + } + }); + }); + $('#location-create').click(function () { launchModalForm("{% url 'stock-location-create' %}", { diff --git a/InvenTree/stock/templates/stock/location_list.html b/InvenTree/stock/templates/stock/location_list.html index 90d01c449c..9ce7a1814c 100644 --- a/InvenTree/stock/templates/stock/location_list.html +++ b/InvenTree/stock/templates/stock/location_list.html @@ -7,8 +7,8 @@ Sub-Locations{{ children|length }} {% block collapse_content %} diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index 437464a77a..95eb1002ad 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -67,15 +67,18 @@ class StockTest(TestCase): # Move one of the drawers self.drawer3.parent = self.home + self.drawer3.save() + self.assertNotEqual(self.drawer3.parent, self.office) self.assertEqual(self.drawer3.pathstring, 'Home/Drawer_3') def test_children(self): self.assertTrue(self.office.has_children) + self.assertFalse(self.drawer2.has_children) - childs = self.office.getUniqueChildren() + childs = [item.pk for item in self.office.getUniqueChildren()] self.assertIn(self.drawer1.id, childs) self.assertIn(self.drawer2.id, childs) diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 76fbaae669..fa849211f3 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -51,6 +51,9 @@ stock_urls = [ url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'), + url(r'^export-options/?', views.StockExportOptions.as_view(), name='stock-export-options'), + url(r'^export/?', views.StockExport.as_view(), name='stock-export'), + # Individual stock items url(r'^item/(?P\d+)/', include(stock_item_detail_urls)), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 1431cb3693..29b5c5d6bb 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -18,10 +18,14 @@ from InvenTree.views import AjaxView from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView from InvenTree.views import QRCodeView -from InvenTree.helpers import str2bool +from InvenTree.status_codes import StockStatus +from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats from InvenTree.helpers import ExtractSerialNumbers from datetime import datetime +import tablib + +from company.models import Company from part.models import Part from .models import StockItem, StockLocation, StockItemTracking @@ -31,6 +35,7 @@ from .forms import EditStockItemForm from .forms import AdjustStockForm from .forms import TrackingEntryForm from .forms import SerializeStockForm +from .forms import ExportOptionsForm class StockIndex(ListView): @@ -119,6 +124,178 @@ class StockLocationQRCode(QRCodeView): return None +class StockExportOptions(AjaxView): + """ Form for selecting StockExport options """ + + model = StockLocation + ajax_form_title = 'Stock Export Options' + form_class = ExportOptionsForm + + def post(self, request, *args, **kwargs): + + self.request = request + + fmt = request.POST.get('file_format', 'csv').lower() + cascade = str2bool(request.POST.get('include_sublocations', False)) + + # Format a URL to redirect to + url = reverse('stock-export') + + url += '?format=' + fmt + url += '&cascade=' + str(cascade) + + data = { + 'form_valid': True, + 'format': fmt, + 'cascade': cascade + } + + return self.renderJsonResponse(self.request, self.form_class(), data=data) + + def get(self, request, *args, **kwargs): + return self.renderJsonResponse(request, self.form_class()) + + +class StockExport(AjaxView): + """ Export stock data from a particular location. + Returns a file containing stock information for that location. + """ + + model = StockItem + + def get(self, request, *args, **kwargs): + + export_format = request.GET.get('format', 'csv').lower() + + # Check if a particular location was specified + loc_id = request.GET.get('location', None) + location = None + + if loc_id: + try: + location = StockLocation.objects.get(pk=loc_id) + except (ValueError, StockLocation.DoesNotExist): + pass + + # Check if a particular supplier was specified + sup_id = request.GET.get('supplier', None) + supplier = None + + if sup_id: + try: + supplier = Company.objects.get(pk=sup_id) + except (ValueError, Company.DoesNotExist): + pass + + # Check if a particular part was specified + part_id = request.GET.get('part', None) + part = None + + if part_id: + try: + part = Part.objects.get(pk=part_id) + except (ValueError, Part.DoesNotExist): + pass + + if export_format not in GetExportFormats(): + export_format = 'csv' + + filename = 'InvenTree_Stocktake_{date}.{fmt}'.format( + date=datetime.now().strftime("%d-%b-%Y"), + fmt=export_format + ) + + if location: + # CHeck if locations should be cascading + cascade = str2bool(request.GET.get('cascade', True)) + stock_items = location.get_stock_items(cascade) + else: + cascade = True + stock_items = StockItem.objects.all() + + if part: + stock_items = stock_items.filter(part=part) + + if supplier: + stock_items = stock_items.filter(supplier_part__supplier=supplier) + + # Filter out stock items that are not 'in stock' + stock_items = stock_items.filter(customer=None) + stock_items = stock_items.filter(belongs_to=None) + + # Column headers + headers = [ + _('Stock ID'), + _('Part ID'), + _('Part'), + _('Supplier Part ID'), + _('Supplier ID'), + _('Supplier'), + _('Location ID'), + _('Location'), + _('Quantity'), + _('Batch'), + _('Serial'), + _('Status'), + _('Notes'), + _('Review Needed'), + _('Last Updated'), + _('Last Stocktake'), + _('Purchase Order ID'), + _('Build ID'), + ] + + data = tablib.Dataset(headers=headers) + + for item in stock_items: + line = [] + + line.append(item.pk) + line.append(item.part.pk) + line.append(item.part.full_name) + + if item.supplier_part: + line.append(item.supplier_part.pk) + line.append(item.supplier_part.supplier.pk) + line.append(item.supplier_part.supplier.name) + else: + line.append('') + line.append('') + line.append('') + + if item.location: + line.append(item.location.pk) + line.append(item.location.name) + else: + line.append('') + line.append('') + + line.append(item.quantity) + line.append(item.batch) + line.append(item.serial) + line.append(StockStatus.label(item.status)) + line.append(item.notes) + line.append(item.review_needed) + line.append(item.updated) + line.append(item.stocktake_date) + + if item.purchase_order: + line.append(item.purchase_order.pk) + else: + line.append('') + + if item.build: + line.append(item.build.pk) + else: + line.append('') + + data.append(line) + + filedata = data.export(export_format) + + return DownloadFile(filedata, filename) + + class StockItemQRCode(QRCodeView): """ View for displaying a QR code for a StockItem object """ diff --git a/InvenTree/templates/about.html b/InvenTree/templates/about.html index da5b4ddc5b..aef286f6cf 100644 --- a/InvenTree/templates/about.html +++ b/InvenTree/templates/about.html @@ -17,10 +17,10 @@

InvenTree Version Information

- + - + diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html index e53daa4718..5b707eab24 100644 --- a/InvenTree/templates/stock_table.html +++ b/InvenTree/templates/stock_table.html @@ -1,5 +1,6 @@
+ {% if not part or part.is_template == False %} {% endif %} diff --git a/Makefile b/Makefile index 7ea3f33b03..f6b054dab0 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,8 @@ clean: rm -rf .tox rm -f .coverage +update: backup migrate + # Perform database migrations (after schema changes are made) migrate: python3 InvenTree/manage.py makemigrations common @@ -48,7 +50,7 @@ test: # Run code coverage coverage: python3 InvenTree/manage.py check - coverage run InvenTree/manage.py test build common company order part stock InvenTree + coverage run InvenTree/manage.py test build common company order part stock InvenTree coverage html # Install packages required to generate code docs @@ -64,4 +66,4 @@ backup: python3 InvenTree/manage.py dbbackup python3 InvenTree/manage.py mediabackup -.PHONY: clean migrate superuser install mysql style test coverage docreqs docs backup \ No newline at end of file +.PHONY: clean migrate superuser install mysql style test coverage docreqs docs backup update \ No newline at end of file diff --git a/docs/modules.rst b/docs/modules.rst index 06078ad6e2..6509965972 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -9,6 +9,7 @@ InvenTree Modules docs/InvenTree/index docs/build/index + docs/common/index docs/company/index docs/part/index docs/order/index @@ -18,6 +19,7 @@ The InvenTree Django ecosystem provides the following 'apps' for core functional * `InvenTree `_ - High level management functions * `Build `_ - Part build projects +* `Common `_ - Common modules used by various apps * `Company `_ - Company management (suppliers / customers) * `Part `_ - Part management * `Order `_ - Order management diff --git a/docs/update.rst b/docs/update.rst index d6ef6a5913..d3266b4126 100644 --- a/docs/update.rst +++ b/docs/update.rst @@ -29,7 +29,13 @@ Perform Migrations Updating the database is as simple as calling the makefile target: -``make migrate`` +``make update`` + +This command performs the following steps: + +* Backup database entries and uploaded media files +* Perform required database schema changes +* Collect required static files Restart Server -------------- diff --git a/requirements.txt b/requirements.txt index cf45d83248..0f94e4cc14 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ pillow>=5.0.0 # Image manipulation djangorestframework>=3.6.2 # DRF framework django-cors-headers>=2.5.3 # CORS headers extension for DRF django_filter>=1.0.2 # Extended filtering options +django-mptt>=0.10.0 # Modified Preorder Tree Traversal django-dbbackup==3.2.0 # Database backup / restore functionality coreapi>=2.3.0 # API documentation pygments>=2.2.0 # Syntax highlighting
Version{% inventree_version %}Version{% inventree_version %}
Commit Hash{% inventree_commit %}Commit Hash{% inventree_commit %}