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/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/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/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_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 %}
Version | {% inventree_version %} | +Version | {% inventree_version %} |
Commit Hash | {% inventree_commit %} | +Commit Hash | {% inventree_commit %} |
diff --git a/Makefile b/Makefile index 7ea3f33b03..38446635da 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,8 @@ clean: rm -rf .tox rm -f .coverage +update: backup migrate + # Perform database migrations (after schema changes are made) migrate: python3 InvenTree/manage.py makemigrations common @@ -64,4 +66,4 @@ backup: python3 InvenTree/manage.py dbbackup python3 InvenTree/manage.py mediabackup -.PHONY: clean migrate superuser install mysql style test coverage docreqs docs backup \ No newline at end of file +.PHONY: clean migrate superuser install mysql style test coverage docreqs docs backup update \ No newline at end of file diff --git a/docs/update.rst b/docs/update.rst index d6ef6a5913..d3266b4126 100644 --- a/docs/update.rst +++ b/docs/update.rst @@ -29,7 +29,13 @@ Perform Migrations Updating the database is as simple as calling the makefile target: -``make migrate`` +``make update`` + +This command performs the following steps: + +* Backup database entries and uploaded media files +* Perform required database schema changes +* Collect required static files Restart Server -------------- diff --git a/requirements.txt b/requirements.txt index cf45d83248..0f94e4cc14 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ pillow>=5.0.0 # Image manipulation djangorestframework>=3.6.2 # DRF framework django-cors-headers>=2.5.3 # CORS headers extension for DRF django_filter>=1.0.2 # Extended filtering options +django-mptt>=0.10.0 # Modified Preorder Tree Traversal django-dbbackup==3.2.0 # Database backup / restore functionality coreapi>=2.3.0 # API documentation pygments>=2.2.0 # Syntax highlighting |