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 %}
{% for child in children %}
-- {{ child.name }} - {{ child.description }}
- {{ child.partcount }}
+- {{ child.name }} - {{ child.description }}
+ {{ child.item_count }}
{% endfor %}
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
- Version | {% inventree_version %} |
+ Version | {% inventree_version %} |
- Commit Hash | {% inventree_commit %} |
+ Commit Hash | {% inventree_commit %} |
|
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 @@