diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index f9d3399eae..54719ef17e 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -97,6 +97,10 @@ class Build(models.Model): notes = MarkdownxField(blank=True, help_text=_('Extra build notes')) + @property + def output_count(self): + return self.build_outputs.count() + @transaction.atomic def cancelBuild(self, user): """ Mark the Build as CANCELLED @@ -235,7 +239,7 @@ class Build(models.Model): now=str(datetime.now().date()) ) - if self.part.trackable: + if self.part.trackable and serial_numbers: # Add new serial numbers for serial in serial_numbers: item = StockItem.objects.create( diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index 3ab3fc05c0..d1842cd384 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -90,6 +90,10 @@ InvenTree | Build - {{ build }} {% endblock %} +{% block js_load %} + +{% endblock %} + {% block js_ready %} $("#build-edit").click(function () { diff --git a/InvenTree/build/templates/build/build_output.html b/InvenTree/build/templates/build/build_output.html new file mode 100644 index 0000000000..f35672bf79 --- /dev/null +++ b/InvenTree/build/templates/build/build_output.html @@ -0,0 +1,32 @@ +{% extends "build/build_base.html" %} +{% load static %} +{% load i18n %} + +{% block details %} + +{% include "build/tabs.html" with tab='output' %} + +

{% trans "Build Outputs" %}

+
+ +{% include "stock_table.html" %} + +{% endblock %} + +{% block js_ready %} +{{ block.super }} + +loadStockTable($("#stock-table"), { + params: { + location_detail: true, + part_details: true, + build: {{ build.id }}, + }, + groupByField: 'location', + buttons: [ + '#stock-options', + ], + url: "{% url 'api-stock-list' %}", +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/tabs.html b/InvenTree/build/templates/build/tabs.html index e9f63cc05b..552bac7408 100644 --- a/InvenTree/build/templates/build/tabs.html +++ b/InvenTree/build/templates/build/tabs.html @@ -4,6 +4,9 @@ {% trans "Details" %} + + {% trans "Outputs" %}{% if build.output_count > 0%}{{ build.output_count }}{% endif %} + {% trans "Notes" %}{% if build.notes %} {% endif %} diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index 6fa7a3c304..5d23c55a2d 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -26,6 +26,9 @@ build_detail_urls = [ url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'), url(r'^notes/', views.BuildNotes.as_view(), name='build-notes'), + + url(r'^output/', views.BuildDetail.as_view(template_name='build/build_output.html'), name='build-output'), + url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), ] diff --git a/InvenTree/common/apps.py b/InvenTree/common/apps.py index b2b4b506c1..d055aeba74 100644 --- a/InvenTree/common/apps.py +++ b/InvenTree/common/apps.py @@ -1,5 +1,5 @@ from django.apps import AppConfig -from django.db.utils import OperationalError +from django.db.utils import OperationalError, ProgrammingError import os @@ -43,6 +43,6 @@ class CommonConfig(AppConfig): setting.save() print("Creating new key: '{k}' = '{v}'".format(k=key, v=default)) - except OperationalError: + except (OperationalError, ProgrammingError): # Migrations have not yet been applied - table does not exist break diff --git a/InvenTree/stock/admin.py b/InvenTree/stock/admin.py index c33010bf3b..8c91518de0 100644 --- a/InvenTree/stock/admin.py +++ b/InvenTree/stock/admin.py @@ -85,6 +85,13 @@ class StockItemResource(ModelResource): stocktake_date = Field(attribute='stocktake_date', widget=widgets.DateWidget()) + def after_import(self, dataset, result, using_transactions, dry_run, **kwargs): + + super().after_import(dataset, result, using_transactions, dry_run, **kwargs) + + # Rebuild the StockItem tree(s) + StockItem.objects.rebuild() + class Meta: model = StockItem skip_unchanged = True diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index af2d724f58..86759a90aa 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -257,8 +257,12 @@ class StockList(generics.ListCreateAPIView): - location: Filter stock by location - category: Filter by parts belonging to a certain category - supplier: Filter by supplier + - ancestor: Filter by an 'ancestor' StockItem + - status: Filter by the StockItem status """ + queryset = StockItem.objects.all() + def get_serializer(self, *args, **kwargs): try: @@ -284,6 +288,7 @@ class StockList(generics.ListCreateAPIView): data = queryset.values( 'pk', + 'parent', 'quantity', 'serial', 'batch', @@ -332,7 +337,9 @@ class StockList(generics.ListCreateAPIView): """ # Start with all objects - stock_list = StockItem.objects.filter(customer=None, belongs_to=None) + stock_list = super(StockList, self).get_queryset() + + stock_list = stock_list.filter(customer=None, belongs_to=None) # Does the client wish to filter by the Part ID? part_id = self.request.query_params.get('part', None) @@ -347,7 +354,20 @@ class StockList(generics.ListCreateAPIView): else: stock_list = stock_list.filter(part=part_id) - except Part.DoesNotExist: + except (ValueError, Part.DoesNotExist): + pass + + # Does the client wish to filter by the 'ancestor'? + anc_id = self.request.query_params.get('ancestor', None) + + if anc_id: + try: + ancestor = StockItem.objects.get(pk=anc_id) + + # Only allow items which are descendants of the specified StockItem + stock_list = stock_list.filter(id__in=[item.pk for item in ancestor.children.all()]) + + except (ValueError, Part.DoesNotExist): pass # Does the client wish to filter by stock location? @@ -358,7 +378,7 @@ class StockList(generics.ListCreateAPIView): location = StockLocation.objects.get(pk=loc_id) stock_list = stock_list.filter(location__in=location.getUniqueChildren()) - except StockLocation.DoesNotExist: + except (ValueError, StockLocation.DoesNotExist): pass # Does the client wish to filter by part category? @@ -369,9 +389,15 @@ class StockList(generics.ListCreateAPIView): category = PartCategory.objects.get(pk=cat_id) stock_list = stock_list.filter(part__category__in=category.getUniqueChildren()) - except PartCategory.DoesNotExist: + except (ValueError, PartCategory.DoesNotExist): pass + # Filter by StockItem status + status = self.request.query_params.get('status', None) + + if status: + stock_list = stock_list.filter(status=status) + # Filter by supplier_part ID supplier_part_id = self.request.query_params.get('supplier_part', None) @@ -411,7 +437,7 @@ class StockList(generics.ListCreateAPIView): 'supplier_part', 'customer', 'belongs_to', - # 'status' TODO - There are some issues filtering based on an enumeration field + 'build' ] diff --git a/InvenTree/stock/fixtures/stock.yaml b/InvenTree/stock/fixtures/stock.yaml index 96e0a3ab72..ebc207f29c 100644 --- a/InvenTree/stock/fixtures/stock.yaml +++ b/InvenTree/stock/fixtures/stock.yaml @@ -7,6 +7,10 @@ location: 3 batch: 'B123' quantity: 4000 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 # 5,000 screws in the bathroom - model: stock.stockitem @@ -14,6 +18,10 @@ part: 1 location: 2 quantity: 5000 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 # 1234 2K2 resistors in 'Drawer_1' - model: stock.stockitem @@ -22,6 +30,10 @@ part: 3 location: 5 quantity: 1234 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 # Some widgets in drawer 3 - model: stock.stockitem @@ -31,6 +43,10 @@ location: 7 quantity: 10 delete_on_deplete: False + level: 0 + tree_id: 0 + lft: 0 + rght: 0 - model: stock.stockitem pk: 101 @@ -38,10 +54,18 @@ part: 25 location: 7 quantity: 5 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 - model: stock.stockitem pk: 102 fields: part: 25 location: 7 - quantity: 3 \ No newline at end of file + quantity: 3 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 \ No newline at end of file diff --git a/InvenTree/stock/migrations/0021_auto_20200215_2232.py b/InvenTree/stock/migrations/0021_auto_20200215_2232.py new file mode 100644 index 0000000000..3ecca4d6f5 --- /dev/null +++ b/InvenTree/stock/migrations/0021_auto_20200215_2232.py @@ -0,0 +1,44 @@ +# Generated by Django 2.2.9 on 2020-02-15 22:32 + +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0020_auto_20200206_1213'), + ] + + operations = [ + migrations.AddField( + model_name='stockitem', + name='level', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='stockitem', + name='lft', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='stockitem', + name='parent', + field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='stock.StockItem'), + ), + migrations.AddField( + model_name='stockitem', + name='rght', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='stockitem', + name='tree_id', + field=models.PositiveIntegerField(db_index=True, default=0, editable=False), + preserve_default=False, + ), + ] diff --git a/InvenTree/stock/migrations/0022_auto_20200217_1109.py b/InvenTree/stock/migrations/0022_auto_20200217_1109.py new file mode 100644 index 0000000000..0db3985361 --- /dev/null +++ b/InvenTree/stock/migrations/0022_auto_20200217_1109.py @@ -0,0 +1,21 @@ +# Generated by Django 2.2.9 on 2020-02-17 11:09 + +from django.db import migrations +from stock import models + + +def update_stock_item_tree(apps, schema_editor): + # Update the StockItem MPTT model + + models.StockItem.objects.rebuild() + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0021_auto_20200215_2232'), + ] + + operations = [ + migrations.RunPython(update_stock_item_tree) + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 57f390415f..43d3eef145 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -18,7 +18,7 @@ from django.dispatch import receiver from markdownx.models import MarkdownxField -from mptt.models import TreeForeignKey +from mptt.models import MPTTModel, TreeForeignKey from decimal import Decimal, InvalidOperation from datetime import datetime @@ -102,11 +102,12 @@ def before_delete_stock_location(sender, instance, using, **kwargs): child.save() -class StockItem(models.Model): +class StockItem(MPTTModel): """ A StockItem object represents a quantity of physical instances of a part. Attributes: + parent: Link to another StockItem from which this StockItem was created part: Link to the master abstract part that this StockItem is an instance of supplier_part: Link to a specific SupplierPart (optional) location: Where this StockItem is located @@ -296,6 +297,11 @@ class StockItem(models.Model): } ) + parent = TreeForeignKey('self', + on_delete=models.DO_NOTHING, + blank=True, null=True, + related_name='children') + part = models.ForeignKey('part.Part', on_delete=models.CASCADE, related_name='stock_items', help_text=_('Base part'), limit_choices_to={ @@ -370,15 +376,31 @@ class StockItem(models.Model): def can_delete(self): """ Can this stock item be deleted? It can NOT be deleted under the following circumstances: + - Has child StockItems - Has a serial number and is tracked - Is installed inside another StockItem """ + if self.child_count > 0: + return False + if self.part.trackable and self.serial is not None: return False return True + @property + def children(self): + """ Return a list of the child items which have been split from this stock item """ + return self.get_descendants(include_self=False) + + @property + def child_count(self): + """ Return the number of 'child' items associated with this StockItem. + A child item is one which has been split from this one. + """ + return self.children.count() + @property def in_stock(self): @@ -469,6 +491,7 @@ class StockItem(models.Model): new_item.quantity = 1 new_item.serial = serial new_item.pk = None + new_item.parent = self if location: new_item.location = location @@ -496,13 +519,14 @@ class StockItem(models.Model): item.save() @transaction.atomic - def splitStock(self, quantity, user): + def splitStock(self, quantity, location, user): """ Split this stock item into two items, in the same location. Stock tracking notes for this StockItem will be duplicated, and added to the new StockItem. Args: quantity: Number of stock items to remove from this entity, and pass to the next + location: Where to move the new StockItem to Notes: The provided quantity will be subtracted from this item and given to the new one. @@ -530,7 +554,15 @@ class StockItem(models.Model): # Nullify the PK so a new record is created new_stock = StockItem.objects.get(pk=self.pk) new_stock.pk = None + new_stock.parent = self new_stock.quantity = quantity + + # Move to the new location if specified, otherwise use current location + if location: + new_stock.location = location + else: + new_stock.location = self.location + new_stock.save() # Copy the transaction history of this part into the new one @@ -549,6 +581,11 @@ class StockItem(models.Model): def move(self, location, notes, user, **kwargs): """ Move part to a new location. + If less than the available quantity is to be moved, + a new StockItem is created, with the defined quantity, + and that new StockItem is moved. + The quantity is also subtracted from the existing StockItem. + Args: location: Destination location (cannot be null) notes: User notes @@ -576,8 +613,10 @@ class StockItem(models.Model): if quantity < self.quantity: # We need to split the stock! - # Leave behind certain quantity - self.splitStock(self.quantity - quantity, user) + # Split the existing StockItem in two + self.splitStock(quantity, location, user) + + return True msg = "Moved to {loc}".format(loc=str(location)) @@ -586,10 +625,11 @@ class StockItem(models.Model): self.location = location - self.addTransactionNote(msg, - user, - notes=notes, - system=True) + self.addTransactionNote( + msg, + user, + notes=notes, + system=True) self.save() @@ -727,6 +767,23 @@ class StockItem(models.Model): return s +@receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log') +def before_delete_stock_item(sender, instance, using, **kwargs): + """ Receives pre_delete signal from StockItem object. + + Before a StockItem is deleted, ensure that each child object is updated, + to point to the new parent item. + """ + + # Update each StockItem parent field + for child in instance.children.all(): + child.parent = instance.parent + child.save() + + # Rebuild the MPTT tree + StockItem.objects.rebuild() + + class StockItemTracking(models.Model): """ Stock tracking entry - breacrumb for keeping track of automated stock transactions diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 5caead54a7..3e398ba16a 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -43,21 +43,32 @@ + {% if item.can_delete %} + {% endif %}

{% if item.serialized %}
{% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %}
+ {% elif item.child_count > 0 %} +
+ {% trans "This stock item cannot be deleted as it has child items" %} +
{% elif item.delete_on_deplete %}
{% trans "This stock item will be automatically deleted when all stock is depleted." %}
{% endif %} - + {% if item.parent %} +
+ {% trans "This stock item was split from " %}{{ item.parent }} +
+ {% endif %} +
diff --git a/InvenTree/stock/templates/stock/item_childs.html b/InvenTree/stock/templates/stock/item_childs.html new file mode 100644 index 0000000000..1a8febbbea --- /dev/null +++ b/InvenTree/stock/templates/stock/item_childs.html @@ -0,0 +1,42 @@ +{% extends "stock/item_base.html" %} + +{% load static %} +{% load i18n %} + +{% block details %} + +{% include "stock/tabs.html" with tab='children' %} + +
+ +

{% trans "Child Stock Items" %}

+ +{% if item.child_count > 0 %} +{% include "stock_table.html" %} +{% else %} +
+ {% trans "This stock item does not have any child items" %} +
+{% endif %} + +{% endblock %} + +{% block js_ready %} +{{ block.super }} + +{% if item.child_count > 0 %} +loadStockTable($("#stock-table"), { + params: { + location_detail: true, + part_details: true, + ancestor: {{ item.id }}, + }, + groupByField: 'location', + buttons: [ + '#stock-options', + ], + url: "{% url 'api-stock-list' %}", +}); +{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/stock/templates/stock/tabs.html b/InvenTree/stock/templates/stock/tabs.html index ac381a76ab..378f74e28e 100644 --- a/InvenTree/stock/templates/stock/tabs.html +++ b/InvenTree/stock/templates/stock/tabs.html @@ -4,6 +4,9 @@ {% trans "Tracking" %} + + {% trans "Children" %}{% if item.child_count > 0 %}{{ item.child_count }}{% endif %} + {% if 0 %} diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index 2f840833a3..a866bdb880 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -156,7 +156,9 @@ class StockTest(TestCase): # Move 6 of the units self.assertTrue(w1.move(self.diningroom, 'Moved', None, quantity=6)) - self.assertEqual(w1.quantity, 6) + + # There should be 4 remaining + self.assertEqual(w1.quantity, 4) # There should also be a new object still in drawer3 self.assertEqual(StockItem.objects.filter(part=25).count(), 4) @@ -175,17 +177,17 @@ class StockTest(TestCase): N = StockItem.objects.filter(part=3).count() stock = StockItem.objects.get(id=1234) - stock.splitStock(1000, None) + stock.splitStock(1000, None, self.user) self.assertEqual(stock.quantity, 234) # There should be a new stock item too! self.assertEqual(StockItem.objects.filter(part=3).count(), N + 1) # Try to split a negative quantity - stock.splitStock(-10, None) + stock.splitStock(-10, None, self.user) self.assertEqual(StockItem.objects.filter(part=3).count(), N + 1) - stock.splitStock(stock.quantity, None) + stock.splitStock(stock.quantity, None, self.user) self.assertEqual(StockItem.objects.filter(part=3).count(), N + 1) def test_stocktake(self): @@ -325,6 +327,3 @@ class StockTest(TestCase): # Serialize the remainder of the stock item.serializeStock(2, [99, 100], self.user) - - # Two more items but the original has been deleted - self.assertEqual(StockItem.objects.filter(part=25).count(), n + 9) diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 50ac170ce3..f69dc8b63f 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -24,6 +24,7 @@ stock_item_detail_urls = [ url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'), + url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'), url(r'^notes/', views.StockItemNotes.as_view(), name='stock-item-notes'), url('^.*$', views.StockItemDetail.as_view(), name='stock-item-detail'), diff --git a/docs/start.rst b/docs/start.rst index 2cac8c6a95..1b389d7e5b 100644 --- a/docs/start.rst +++ b/docs/start.rst @@ -35,7 +35,7 @@ To configure Inventree inside a virtual environment, ``cd`` into the inventree b ``source inventree-env/bin/activate`` -This will place the current shell session inside a virtual environment - the terminal should display the ``(inventree)`` prefix. +This will place the current shell session inside a virtual environment - the terminal should display the ``(inventree-env)`` prefix. .. note:: Remember to run ``source inventree-env/bin/activate`` when starting each shell session, before running Inventree commands. This will ensure that the correct environment is being used. diff --git a/requirements.txt b/requirements.txt index 8edd16379e..4ce7c5d831 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==2.2.10 # Django package +Django==2.2.9 # Django package pillow==6.2.0 # Image manipulation djangorestframework==3.10.3 # DRF framework django-cors-headers==3.2.0 # CORS headers extension for DRF @@ -18,4 +18,4 @@ flake8==3.3.0 # PEP checking coverage==4.0.3 # Unit test coverage python-coveralls==2.9.1 # Coveralls linking (for Travis) fuzzywuzzy==0.17.0 # Fuzzy string matching -python-Levenshtein==0.12.0 # Required for fuzzywuzzy \ No newline at end of file +python-Levenshtein==0.12.0 # Required for fuzzywuzzy