Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-09-09 00:09:58 +10:00
commit abecb4856f
41 changed files with 779 additions and 128 deletions

View File

@ -94,6 +94,18 @@ def MakeBarcode(object_type, object_id, object_url, data={}):
return json.dumps(data, sort_keys=True) 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'): def DownloadFile(data, filename, content_type='application/text'):
""" Create a dynamic file for the user to download. """ Create a dynamic file for the user to download.

View File

@ -11,10 +11,12 @@ from rest_framework.exceptions import ValidationError
from django.db.models.signals import pre_delete from django.db.models.signals import pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from mptt.models import MPTTModel, TreeForeignKey
from .validators import validate_tree_name from .validators import validate_tree_name
class InvenTreeTree(models.Model): class InvenTreeTree(MPTTModel):
""" Provides an abstracted self-referencing tree model for data categories. """ 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). - 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 abstract = True
unique_together = ('name', 'parent') unique_together = ('name', 'parent')
class MPTTMeta:
order_insertion_by = ['name']
name = models.CharField( name = models.CharField(
blank=False, blank=False,
max_length=100, max_length=100,
@ -43,7 +48,7 @@ class InvenTreeTree(models.Model):
) )
# When a category is deleted, graft the children onto its parent # When a category is deleted, graft the children onto its parent
parent = models.ForeignKey('self', parent = TreeForeignKey('self',
on_delete=models.DO_NOTHING, on_delete=models.DO_NOTHING,
blank=True, blank=True,
null=True, null=True,
@ -60,59 +65,31 @@ class InvenTreeTree(models.Model):
""" """
return 0 return 0
def getUniqueParents(self, unique=None): def getUniqueParents(self):
""" Return a flat set of all parent items that exist above this node. """ 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 If any parents are repeated (which would be very bad!), the process is halted
""" """
item = self return self.get_ancestors()
# Prevent infinite regression def getUniqueChildren(self, include_self=True):
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):
""" Return a flat set of all child items that exist under this node. """ Return a flat set of all child items that exist under this node.
If any child items are repeated, the repetitions are omitted. If any child items are repeated, the repetitions are omitted.
""" """
if unique is None: return self.get_descendants(include_self=include_self)
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
@property @property
def has_children(self): def has_children(self):
""" True if there are any children under this item """ """ 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): def getAcceptableParents(self):
""" Returns a list of acceptable parent items within this model """ Returns a list of acceptable parent items within this model
Acceptable parents are ones which are not underneath this item. Acceptable parents are ones which are not underneath this item.
Setting the parent of an item to its own child results in recursion. Setting the parent of an item to its own child results in recursion.
""" """
contents = ContentType.objects.get_for_model(type(self)) contents = ContentType.objects.get_for_model(type(self))
available = contents.get_all_objects_for_this_type() 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 List of category names from the top level to the parent of this category
""" """
if self.parent: return [a for a in self.get_ancestors()]
return self.parent.parentpath + [self.parent]
else:
return []
@property @property
def path(self): def path(self):
@ -183,7 +157,7 @@ class InvenTreeTree(models.Model):
pass pass
# Ensure that the new parent is not already a child # 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") raise ValidationError("Category cannot set a child as parent")
def __str__(self): def __str__(self):

View File

@ -58,9 +58,7 @@ cors_opt = CONFIG.get('cors', None)
if cors_opt: if cors_opt:
CORS_ORIGIN_ALLOW_ALL = cors_opt.get('allow_all', False) CORS_ORIGIN_ALLOW_ALL = cors_opt.get('allow_all', False)
if CORS_ORIGIN_ALLOW_ALL: if not CORS_ORIGIN_ALLOW_ALL:
eprint("Warning: CORS requests are allowed for any domain!")
else:
CORS_ORIGIN_WHITELIST = cors_opt.get('whitelist', []) CORS_ORIGIN_WHITELIST = cors_opt.get('whitelist', [])
if DEBUG: if DEBUG:
@ -100,6 +98,7 @@ INSTALLED_APPS = [
'import_export', # Import / export tables to file 'import_export', # Import / export tables to file
'django_cleanup', # Automatically delete orphaned MEDIA files 'django_cleanup', # Automatically delete orphaned MEDIA files
'qr_code', # Generate QR codes 'qr_code', # Generate QR codes
'mptt', # Modified Preorder Tree Traversal
] ]
LOGGING = { LOGGING = {

View File

@ -330,7 +330,9 @@ function loadBomTable(table, options) {
}, },
{ {
method: 'PATCH', method: 'PATCH',
reloadOnSuccess: true success: function() {
reloadBomTable(table);
}
} }
); );
}); });

View File

@ -76,12 +76,14 @@ function loadStockTable(table, options) {
} }
else if (field == 'quantity') { else if (field == 'quantity') {
var stock = 0; var stock = 0;
var items = 0;
data.forEach(function(item) { data.forEach(function(item) {
stock += item.quantity; stock += item.quantity;
items += 1;
}); });
return stock; return stock + " (" + items + " items)";
} else if (field == 'batch') { } else if (field == 'batch') {
var batches = []; var batches = [];

View File

@ -10,7 +10,7 @@ class StatusCode:
@classmethod @classmethod
def label(cls, value): def label(cls, value):
""" Return the status code label associated with the provided 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): class OrderStatus(StatusCode):

View File

@ -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 %} {% endblock %}

View File

@ -247,6 +247,7 @@ class PurchaseOrder(Order):
if line.part: if line.part:
stock = StockItem( stock = StockItem(
part=line.part.part, part=line.part.part,
supplier_part=line.part,
location=location, location=location,
quantity=quantity, quantity=quantity,
purchase_order=self) purchase_order=self)

View File

@ -25,6 +25,27 @@ InvenTree | {{ order }}
{% if order.URL %} {% if order.URL %}
<a href="{{ order.URL }}">{{ order.URL }}</a> <a href="{{ order.URL }}">{{ order.URL }}</a>
{% endif %} {% endif %}
<p>
<div class='btn-row'>
<div class='btn-group'>
<button type='button' class='btn btn-default btn-glyph' id='edit-order' title='Edit order information'>
<span class='glyphicon glyphicon-edit'></span>
</button>
<button type='button' class='btn btn-default btn-glyph' id='export-order' title='Export order to file'>
<span class='glyphicon glyphicon-download-alt'></span>
</button>
{% if order.status == OrderStatus.PENDING and order.lines.count > 0 %}
<button type='button' class='btn btn-default btn-glyph' id='place-order' title='Place order'>
<span class='glyphicon glyphicon-send'></span>
</button>
{% elif order.status == OrderStatus.PLACED %}
<button type='button' class='btn btn-default btn-glyph' id='receive-order' title='Receive items'>
<span class='glyphicon glyphicon-check'></span>
</button>
{% endif %}
</div>
</div>
</p>
</div> </div>
</div> </div>
</div> </div>
@ -65,13 +86,6 @@ InvenTree | {{ order }}
{% if order.status == OrderStatus.PENDING %} {% if order.status == OrderStatus.PENDING %}
<button type='button' class='btn btn-default' id='new-po-line'>Add Line Item</button> <button type='button' class='btn btn-default' id='new-po-line'>Add Line Item</button>
{% endif %} {% endif %}
<button type='button' class='btn btn-primary' id='edit-order'>Edit Order</button>
{% if order.status == OrderStatus.PENDING and order.lines.count > 0 %}
<button type='button' class='btn btn-primary' id='place-order'>Place Order</button>
{% elif order.status == OrderStatus.PLACED %}
<button type='button' class='btn btn-primary' id='receive-order'>Receive Items</button>
{% endif %}
<button type='button' class='btn btn-primary' id='export-order' title='Export order to file'>Export</button>
</div> </div>
<h4>Order Items</h4> <h4>Order Items</h4>

View File

@ -27,7 +27,7 @@ InvenTree | Purchase Orders
$("#po-create").click(function() { $("#po-create").click(function() {
launchModalForm("{% url 'purchase-order-create' %}", launchModalForm("{% url 'purchase-order-create' %}",
{ {
reload: true, follow: true,
} }
); );
}); });

View File

@ -6,7 +6,11 @@
name: Electronics name: Electronics
description: Electronic components description: Electronic components
parent: null parent: null
default_location: 1 # Home default_location: 1
level: 0
tree_id: 1
lft: 1
rght: 12
- model: part.partcategory - model: part.partcategory
pk: 2 pk: 2
@ -15,6 +19,10 @@
description: Resistors description: Resistors
parent: 1 parent: 1
default_location: null default_location: null
level: 1
tree_id: 1
lft: 2
rght: 3
- model: part.partcategory - model: part.partcategory
pk: 3 pk: 3
@ -23,6 +31,10 @@
description: Capacitors description: Capacitors
parent: 1 parent: 1
default_location: null default_location: null
level: 1
tree_id: 1
lft: 4
rght: 5
- model: part.partcategory - model: part.partcategory
pk: 4 pk: 4
@ -31,6 +43,10 @@
description: Integrated Circuits description: Integrated Circuits
parent: 1 parent: 1
default_location: null default_location: null
level: 1
tree_id: 1
lft: 6
rght: 11
- model: part.partcategory - model: part.partcategory
pk: 5 pk: 5
@ -39,6 +55,10 @@
description: Microcontrollers description: Microcontrollers
parent: 4 parent: 4
default_location: null default_location: null
level: 2
tree_id: 1
lft: 7
rght: 8
- model: part.partcategory - model: part.partcategory
pk: 6 pk: 6
@ -47,6 +67,10 @@
description: Communication interfaces description: Communication interfaces
parent: 4 parent: 4
default_location: null default_location: null
level: 2
tree_id: 1
lft: 9
rght: 10
- model: part.partcategory - model: part.partcategory
pk: 7 pk: 7
@ -54,6 +78,10 @@
name: Mechanical name: Mechanical
description: Mechanical componenets description: Mechanical componenets
default_location: null default_location: null
level: 0
tree_id: 2
lft: 1
rght: 4
- model: part.partcategory - model: part.partcategory
pk: 8 pk: 8
@ -62,3 +90,7 @@
description: Screws, bolts, etc description: Screws, bolts, etc
parent: 7 parent: 7
default_location: 5 default_location: 5
level: 1
tree_id: 2
lft: 2
rght: 3

View File

@ -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

View File

@ -60,3 +60,7 @@
description: 'Can we build it?' description: 'Can we build it?'
assembly: true assembly: true
purchaseable: false purchaseable: false
category: 7
active: False
IPN: BOB
revision: A2

View File

@ -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,
),
]

View File

@ -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)
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -25,6 +25,8 @@ from django.contrib.auth.models import User
from django.db.models.signals import pre_delete from django.db.models.signals import pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from mptt.models import TreeForeignKey
from datetime import datetime from datetime import datetime
from fuzzywuzzy import fuzz from fuzzywuzzy import fuzz
import hashlib import hashlib
@ -48,7 +50,7 @@ class PartCategory(InvenTreeTree):
default_keywords: Default keywords for parts created in this category default_keywords: Default keywords for parts created in this category
""" """
default_location = models.ForeignKey( default_location = TreeForeignKey(
'stock.StockLocation', related_name="default_categories", 'stock.StockLocation', related_name="default_categories",
null=True, blank=True, null=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -64,21 +66,31 @@ class PartCategory(InvenTreeTree):
verbose_name = "Part Category" verbose_name = "Part Category"
verbose_name_plural = "Part Categories" 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 @property
def item_count(self): def item_count(self):
return self.partcount() 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 """ Return the total part count under this category
(including children of child categories) (including children of child categories)
""" """
cats = [self.id] query = self.get_parts(cascade=cascade)
if cascade:
cats += [cat for cat in self.getUniqueChildren()]
query = Part.objects.filter(category__in=cats)
if active: if active:
query = query.filter(active=True) query = query.filter(active=True)
@ -88,7 +100,7 @@ class PartCategory(InvenTreeTree):
@property @property
def has_parts(self): def has_parts(self):
""" True if there are any parts in this category """ """ 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') @receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
@ -253,17 +265,9 @@ class Part(models.Model):
def set_category(self, category): def set_category(self, category):
if not type(category) == PartCategory: # Ignore if the category is already the same
raise ValidationError({ if self.category == category:
'category': _('Invalid object supplied to part.set_category')
})
try:
# Already in this category!
if category == self.category:
return return
except PartCategory.DoesNotExist:
pass
self.category = category self.category = category
self.save() self.save()
@ -340,7 +344,7 @@ class Part(models.Model):
keywords = models.CharField(max_length=250, blank=True, help_text='Part keywords to improve visibility in search results') 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', category = TreeForeignKey(PartCategory, related_name='parts',
null=True, blank=True, null=True, blank=True,
on_delete=models.DO_NOTHING, on_delete=models.DO_NOTHING,
help_text='Part category') help_text='Part category')
@ -353,7 +357,7 @@ class Part(models.Model):
image = models.ImageField(upload_to=rename_part_image, max_length=255, null=True, blank=True) 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, default_location = TreeForeignKey('stock.StockLocation', on_delete=models.SET_NULL,
blank=True, null=True, blank=True, null=True,
help_text='Where is this item normally stored?', help_text='Where is this item normally stored?',
related_name='default_parts') related_name='default_parts')
@ -370,13 +374,11 @@ class Part(models.Model):
return self.default_location return self.default_location
elif self.category: elif self.category:
# Traverse up the category tree until we find a default location # 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: if cat.default_location:
return cat.default_location return cat.default_location
else:
cat = cat.parent
# Default case - no default category found # Default case - no default category found
return None return None
@ -1055,7 +1057,7 @@ class PartParameterTemplate(models.Model):
super().validate_unique(exclude) super().validate_unique(exclude)
try: 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(): if others.exists():
msg = _("Parameter template name must be unique") msg = _("Parameter template name must be unique")
@ -1063,11 +1065,6 @@ class PartParameterTemplate(models.Model):
except PartParameterTemplate.DoesNotExist: except PartParameterTemplate.DoesNotExist:
pass 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) name = models.CharField(max_length=100, help_text='Parameter Name', unique=True)
units = models.CharField(max_length=25, help_text='Parameter Units', blank=True) units = models.CharField(max_length=25, help_text='Parameter Units', blank=True)
@ -1086,7 +1083,7 @@ class PartParameter(models.Model):
def __str__(self): def __str__(self):
# String representation of a PartParameter (used in the admin interface) # String representation of a PartParameter (used in the admin interface)
return "{part} : {param} = {data}{units}".format( return "{part} : {param} = {data}{units}".format(
part=str(self.part), part=str(self.part.full_name),
param=str(self.template.name), param=str(self.template.name),
data=str(self.data), data=str(self.data),
units=str(self.template.units) units=str(self.template.units)
@ -1096,8 +1093,7 @@ class PartParameter(models.Model):
# Prevent multiple instances of a parameter for a single part # Prevent multiple instances of a parameter for a single part
unique_together = ('part', 'template') unique_together = ('part', 'template')
part = models.ForeignKey(Part, on_delete=models.CASCADE, part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='parameters', help_text='Parent Part')
related_name='parameters', help_text='Parent Part')
template = models.ForeignKey(PartParameterTemplate, on_delete=models.CASCADE, related_name='instances', help_text='Parameter Template') template = models.ForeignKey(PartParameterTemplate, on_delete=models.CASCADE, related_name='instances', help_text='Parameter Template')

View File

@ -47,6 +47,21 @@
url: "{% url 'api-stock-list' %}", 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 () { $('#item-create').click(function () {
launchModalForm("{% url 'stock-item-create' %}", { launchModalForm("{% url 'stock-item-create' %}", {
reload: true, reload: true,

View File

@ -19,7 +19,7 @@ class BomItemTest(TestCase):
def test_str(self): def test_str(self):
b = BomItem.objects.get(id=1) 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): def test_has_bom(self):
self.assertFalse(self.orphan.has_bom) self.assertFalse(self.orphan.has_bom)

View File

@ -48,7 +48,7 @@ class CategoryTest(TestCase):
def test_unique_childs(self): def test_unique_childs(self):
""" Test the 'unique_children' functionality """ """ 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.transceivers.id, childs)
self.assertIn(self.ic.id, childs) self.assertIn(self.ic.id, childs)
@ -58,7 +58,7 @@ class CategoryTest(TestCase):
def test_unique_parents(self): def test_unique_parents(self):
""" Test the 'unique_parents' functionality """ """ 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.electronics.id, parents)
self.assertIn(self.ic.id, parents) self.assertIn(self.ic.id, parents)
@ -87,6 +87,12 @@ class CategoryTest(TestCase):
self.assertEqual(self.electronics.partcount(), 3) 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): def test_delete(self):
""" Test that category deletion moves the children properly """ """ Test that category deletion moves the children properly """

View File

@ -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()

View File

@ -1,9 +1,14 @@
# Tests for the Part model
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.test import TestCase from django.test import TestCase
import os import os
from .models import Part from .models import Part
from .models import rename_part_image from .models import rename_part_image, match_part_names
from .templatetags import inventree_extras from .templatetags import inventree_extras
@ -39,6 +44,10 @@ class PartTest(TestCase):
self.C1 = Part.objects.get(name='C_22N_0805') 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): def test_metadata(self):
self.assertEqual(self.R1.name, 'R_2K2_0805') self.assertEqual(self.R1.name, 'R_2K2_0805')
self.assertEqual(self.R1.get_absolute_url(), '/part/3/') self.assertEqual(self.R1.get_absolute_url(), '/part/3/')
@ -70,5 +79,10 @@ class PartTest(TestCase):
self.assertIn(self.R1.name, barcode) self.assertIn(self.R1.name, barcode)
def test_copy(self): def test_copy(self):
self.R2.deepCopy(self.R1, image=True, bom=True) 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)

View File

@ -5,6 +5,10 @@
fields: fields:
name: 'Home' name: 'Home'
description: 'My house' description: 'My house'
level: 0
tree_id: 1
lft: 1
rght: 6
- model: stock.stocklocation - model: stock.stocklocation
pk: 2 pk: 2
@ -12,6 +16,10 @@
name: 'Bathroom' name: 'Bathroom'
description: 'Where I keep my bath' description: 'Where I keep my bath'
parent: 1 parent: 1
level: 1
tree_id: 1
lft: 2
rght: 3
- model: stock.stocklocation - model: stock.stocklocation
pk: 3 pk: 3
@ -19,12 +27,20 @@
name: 'Dining Room' name: 'Dining Room'
description: 'A table lives here' description: 'A table lives here'
parent: 1 parent: 1
level: 0
tree_id: 1
lft: 4
rght: 5
- model: stock.stocklocation - model: stock.stocklocation
pk: 4 pk: 4
fields: fields:
name: 'Office' name: 'Office'
description: 'Place of work' description: 'Place of work'
level: 0
tree_id: 2
lft: 1
rght: 8
- model: stock.stocklocation - model: stock.stocklocation
pk: 5 pk: 5
@ -32,6 +48,10 @@
name: 'Drawer_1' name: 'Drawer_1'
description: 'In my desk' description: 'In my desk'
parent: 4 parent: 4
level: 0
tree_id: 2
lft: 2
rght: 3
- model: stock.stocklocation - model: stock.stocklocation
pk: 6 pk: 6
@ -39,6 +59,10 @@
name: 'Drawer_2' name: 'Drawer_2'
description: 'Also in my desk' description: 'Also in my desk'
parent: 4 parent: 4
level: 0
tree_id: 2
lft: 4
rght: 5
- model: stock.stocklocation - model: stock.stocklocation
pk: 7 pk: 7
@ -46,3 +70,7 @@
name: 'Drawer_3' name: 'Drawer_3'
description: 'Again, in my desk' description: 'Again, in my desk'
parent: 4 parent: 4
level: 0
tree_id: 2
lft: 6
rght: 7

View File

@ -9,6 +9,7 @@ from django import forms
from django.forms.utils import ErrorDict from django.forms.utils import ErrorDict
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from InvenTree.helpers import GetExportFormats
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from .models import StockLocation, StockItem, StockItemTracking 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): class AdjustStockForm(forms.ModelForm):
""" Form for performing simple stock adjustments. """ Form for performing simple stock adjustments.

View File

@ -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,
),
]

View File

@ -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)
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -16,6 +16,8 @@ from django.contrib.auth.models import User
from django.db.models.signals import pre_delete from django.db.models.signals import pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from mptt.models import TreeForeignKey
from datetime import datetime from datetime import datetime
from InvenTree import helpers from InvenTree import helpers
@ -34,9 +36,6 @@ class StockLocation(InvenTreeTree):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('stock-location-detail', kwargs={'pk': self.id}) return reverse('stock-location-detail', kwargs={'pk': self.id})
def has_items(self):
return self.stock_items.count() > 0
def format_barcode(self): def format_barcode(self):
""" Return a JSON string for formatting a barcode for this StockLocation object """ """ 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): def stock_item_count(self, cascade=True):
""" Return the number of StockItem objects which live in or under this category """ Return the number of StockItem objects which live in or under this category
""" """
if cascade: return self.get_stock_items(cascade).count()
query = StockItem.objects.filter(location__in=self.getUniqueChildren())
else:
query = StockItem.objects.filter(location=self)
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 @property
def item_count(self): def item_count(self):
@ -277,7 +293,7 @@ class StockItem(models.Model):
supplier_part = models.ForeignKey('company.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL, 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') help_text='Select a matching supplier part for this stock item')
location = models.ForeignKey(StockLocation, on_delete=models.DO_NOTHING, location = TreeForeignKey(StockLocation, on_delete=models.DO_NOTHING,
related_name='stock_items', blank=True, null=True, related_name='stock_items', blank=True, null=True,
help_text='Where is this stock item located?') help_text='Where is this stock item located?')

View File

@ -67,6 +67,24 @@
sessionStorage.removeItem('inventree-show-part-locations'); 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 () { $('#location-create').click(function () {
launchModalForm("{% url 'stock-location-create' %}", launchModalForm("{% url 'stock-location-create' %}",
{ {

View File

@ -7,8 +7,8 @@ Sub-Locations<span class='badge'>{{ children|length }}</span>
{% block collapse_content %} {% block collapse_content %}
<ul class="list-group"> <ul class="list-group">
{% for child in children %} {% for child in children %}
<li class="list-group-item"><a href="{% url 'stock-location-detail' child.id %}">{{ child.name }}</a> - <i>{{ child.description }}</i></li> <li class="list-group-item"><a href="{% url 'stock-location-detail' child.id %}">{{ child.name }}</a> - <i>{{ child.description }}</i>
<span class='badge'>{{ child.partcount }}</span> <span class='badge'>{{ child.item_count }}</span>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@ -67,15 +67,18 @@ class StockTest(TestCase):
# Move one of the drawers # Move one of the drawers
self.drawer3.parent = self.home self.drawer3.parent = self.home
self.drawer3.save()
self.assertNotEqual(self.drawer3.parent, self.office) self.assertNotEqual(self.drawer3.parent, self.office)
self.assertEqual(self.drawer3.pathstring, 'Home/Drawer_3') self.assertEqual(self.drawer3.pathstring, 'Home/Drawer_3')
def test_children(self): def test_children(self):
self.assertTrue(self.office.has_children) self.assertTrue(self.office.has_children)
self.assertFalse(self.drawer2.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.drawer1.id, childs)
self.assertIn(self.drawer2.id, childs) self.assertIn(self.drawer2.id, childs)

View File

@ -51,6 +51,9 @@ stock_urls = [
url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'), 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 # Individual stock items
url(r'^item/(?P<pk>\d+)/', include(stock_item_detail_urls)), url(r'^item/(?P<pk>\d+)/', include(stock_item_detail_urls)),

View File

@ -18,10 +18,14 @@ from InvenTree.views import AjaxView
from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView
from InvenTree.views import QRCodeView 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 InvenTree.helpers import ExtractSerialNumbers
from datetime import datetime from datetime import datetime
import tablib
from company.models import Company
from part.models import Part from part.models import Part
from .models import StockItem, StockLocation, StockItemTracking from .models import StockItem, StockLocation, StockItemTracking
@ -31,6 +35,7 @@ from .forms import EditStockItemForm
from .forms import AdjustStockForm from .forms import AdjustStockForm
from .forms import TrackingEntryForm from .forms import TrackingEntryForm
from .forms import SerializeStockForm from .forms import SerializeStockForm
from .forms import ExportOptionsForm
class StockIndex(ListView): class StockIndex(ListView):
@ -119,6 +124,178 @@ class StockLocationQRCode(QRCodeView):
return None 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): class StockItemQRCode(QRCodeView):
""" View for displaying a QR code for a StockItem object """ """ View for displaying a QR code for a StockItem object """

View File

@ -17,10 +17,10 @@
<h4>InvenTree Version Information</h4> <h4>InvenTree Version Information</h4>
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tr> <tr>
<td>Version</td><td>{% inventree_version %}</td> <td>Version</td><td><a href="https://github.com/inventree/InvenTree/releases">{% inventree_version %}</a></td>
</tr> </tr>
<tr> <tr>
<td>Commit Hash</td><td>{% inventree_commit %}</td> <td>Commit Hash</td><td><a href="https://github.com/inventree/InvenTree/commit/{% inventree_commit %}">{% inventree_commit %}</a></td>
</tr> </tr>
<tr> <tr>
<td colspan="2"></td> <td colspan="2"></td>

View File

@ -1,5 +1,6 @@
<div id='button-toolbar'> <div id='button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<button class='btn btn-success' id='stock-export' title='Export Stock Information'>Export</button>
{% if not part or part.is_template == False %} {% if not part or part.is_template == False %}
<button class="btn btn-success" id='item-create'>New Stock Item</button> <button class="btn btn-success" id='item-create'>New Stock Item</button>
{% endif %} {% endif %}

View File

@ -7,6 +7,8 @@ clean:
rm -rf .tox rm -rf .tox
rm -f .coverage rm -f .coverage
update: backup migrate
# Perform database migrations (after schema changes are made) # Perform database migrations (after schema changes are made)
migrate: migrate:
python3 InvenTree/manage.py makemigrations common python3 InvenTree/manage.py makemigrations common
@ -64,4 +66,4 @@ backup:
python3 InvenTree/manage.py dbbackup python3 InvenTree/manage.py dbbackup
python3 InvenTree/manage.py mediabackup python3 InvenTree/manage.py mediabackup
.PHONY: clean migrate superuser install mysql style test coverage docreqs docs backup .PHONY: clean migrate superuser install mysql style test coverage docreqs docs backup update

View File

@ -9,6 +9,7 @@ InvenTree Modules
docs/InvenTree/index docs/InvenTree/index
docs/build/index docs/build/index
docs/common/index
docs/company/index docs/company/index
docs/part/index docs/part/index
docs/order/index docs/order/index
@ -18,6 +19,7 @@ The InvenTree Django ecosystem provides the following 'apps' for core functional
* `InvenTree <docs/InvenTree/index.html>`_ - High level management functions * `InvenTree <docs/InvenTree/index.html>`_ - High level management functions
* `Build <docs/build/index.html>`_ - Part build projects * `Build <docs/build/index.html>`_ - Part build projects
* `Common <docs/common/index.html>`_ - Common modules used by various apps
* `Company <docs/company/index.html>`_ - Company management (suppliers / customers) * `Company <docs/company/index.html>`_ - Company management (suppliers / customers)
* `Part <docs/part/index.html>`_ - Part management * `Part <docs/part/index.html>`_ - Part management
* `Order <docs/order/index.html>`_ - Order management * `Order <docs/order/index.html>`_ - Order management

View File

@ -29,7 +29,13 @@ Perform Migrations
Updating the database is as simple as calling the makefile target: 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 Restart Server
-------------- --------------

View File

@ -3,6 +3,7 @@ pillow>=5.0.0 # Image manipulation
djangorestframework>=3.6.2 # DRF framework djangorestframework>=3.6.2 # DRF framework
django-cors-headers>=2.5.3 # CORS headers extension for DRF django-cors-headers>=2.5.3 # CORS headers extension for DRF
django_filter>=1.0.2 # Extended filtering options 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 django-dbbackup==3.2.0 # Database backup / restore functionality
coreapi>=2.3.0 # API documentation coreapi>=2.3.0 # API documentation
pygments>=2.2.0 # Syntax highlighting pygments>=2.2.0 # Syntax highlighting