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)
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.

View File

@ -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,7 +48,7 @@ class InvenTreeTree(models.Model):
)
# When a category is deleted, graft the children onto its parent
parent = models.ForeignKey('self',
parent = TreeForeignKey('self',
on_delete=models.DO_NOTHING,
blank=True,
null=True,
@ -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):

View File

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

View File

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

View File

@ -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 = [];

View File

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

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 %}

View File

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

View File

@ -25,6 +25,27 @@ InvenTree | {{ order }}
{% if order.URL %}
<a href="{{ order.URL }}">{{ order.URL }}</a>
{% 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>
@ -65,13 +86,6 @@ InvenTree | {{ order }}
{% if order.status == OrderStatus.PENDING %}
<button type='button' class='btn btn-default' id='new-po-line'>Add Line Item</button>
{% 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>
<h4>Order Items</h4>

View File

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

View File

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

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?'
assembly: true
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.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:
# Ignore if the category is already the same
if self.category == category:
return
except PartCategory.DoesNotExist:
pass
self.category = category
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')
category = models.ForeignKey(PartCategory, related_name='parts',
category = TreeForeignKey(PartCategory, related_name='parts',
null=True, blank=True,
on_delete=models.DO_NOTHING,
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)
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,
help_text='Where is this item normally stored?',
related_name='default_parts')
@ -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')

View File

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

View File

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

View File

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

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

View File

@ -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,6 +59,10 @@
name: 'Drawer_2'
description: 'Also in my desk'
parent: 4
level: 0
tree_id: 2
lft: 4
rght: 5
- model: stock.stocklocation
pk: 7
@ -46,3 +70,7 @@
name: 'Drawer_3'
description: 'Again, in my desk'
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.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.

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.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,7 +293,7 @@ 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,
location = TreeForeignKey(StockLocation, on_delete=models.DO_NOTHING,
related_name='stock_items', blank=True, null=True,
help_text='Where is this stock item located?')

View File

@ -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' %}",
{

View File

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

View File

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

View File

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

View File

@ -17,10 +17,10 @@
<h4>InvenTree Version Information</h4>
<table class='table table-striped table-condensed'>
<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>
<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>
<td colspan="2"></td>

View File

@ -1,5 +1,6 @@
<div id='button-toolbar'>
<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 %}
<button class="btn btn-success" id='item-create'>New Stock Item</button>
{% endif %}

View File

@ -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
@ -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
.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/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 <docs/InvenTree/index.html>`_ - High level management functions
* `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)
* `Part <docs/part/index.html>`_ - Part 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:
``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
--------------

View File

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