Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2020-02-19 00:03:32 +11:00
commit ef83480f65
19 changed files with 310 additions and 29 deletions

View File

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

View File

@ -90,6 +90,10 @@ InvenTree | Build - {{ build }}
{% endblock %}
{% block js_load %}
<script type='text/javascript' src="{% static 'script/inventree/stock.js' %}"></script>
{% endblock %}
{% block js_ready %}
$("#build-edit").click(function () {

View File

@ -0,0 +1,32 @@
{% extends "build/build_base.html" %}
{% load static %}
{% load i18n %}
{% block details %}
{% include "build/tabs.html" with tab='output' %}
<h4>{% trans "Build Outputs" %}</h4>
<hr>
{% 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 %}

View File

@ -4,6 +4,9 @@
<li{% if tab == 'details' %} class='active'{% endif %}>
<a href="{% url 'build-detail' build.id %}">{% trans "Details" %}</a>
</li>
<li{% if tab == 'output' %} class='active'{% endif %}>
<a href="{% url 'build-output' build.id %}">{% trans "Outputs" %}{% if build.output_count > 0%}<span class='badge'>{{ build.output_count }}</span>{% endif %}</a>
</li>
<li{% if tab == 'notes' %} class='active'{% endif %}>
<a href="{% url 'build-notes' build.id %}">{% trans "Notes" %}{% if build.notes %} <span class='glyphicon glyphicon-small glyphicon-info-sign'></span>{% endif %}</a>
</li>

View File

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

View File

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

View File

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

View File

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

View File

@ -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,6 +54,10 @@
part: 25
location: 7
quantity: 5
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 102
@ -45,3 +65,7 @@
part: 25
location: 7
quantity: 3
level: 0
tree_id: 0
lft: 0
rght: 0

View File

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

View File

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

View File

@ -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,7 +625,8 @@ class StockItem(models.Model):
self.location = location
self.addTransactionNote(msg,
self.addTransactionNote(
msg,
user,
notes=notes,
system=True)
@ -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

View File

@ -43,20 +43,31 @@
<button type='button' class='btn btn-default btn-glyph' id='stock-edit' title='Edit stock item'>
<span class='glyphicon glyphicon-edit'/>
</button>
{% if item.can_delete %}
<button type='button' class='btn btn-default btn-glyph' id='stock-delete' title='Edit stock item'>
<span class='glyphicon glyphicon-trash'/>
</button>
{% endif %}
</div>
</p>
{% if item.serialized %}
<div class='alert alert-block alert-info'>
{% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %}
</div>
{% elif item.child_count > 0 %}
<div class='alert alert-block alert-warning'>
{% trans "This stock item cannot be deleted as it has child items" %}
</div>
{% elif item.delete_on_deplete %}
<div class='alert alert-block alert-warning'>
{% trans "This stock item will be automatically deleted when all stock is depleted." %}
</div>
{% endif %}
{% if item.parent %}
<div class='alert alert-block alert-info'>
{% trans "This stock item was split from " %}<a href="{% url 'stock-item-detail' item.parent.id %}">{{ item.parent }}</a>
</div>
{% endif %}
</div>
<div class='row'>

View File

@ -0,0 +1,42 @@
{% extends "stock/item_base.html" %}
{% load static %}
{% load i18n %}
{% block details %}
{% include "stock/tabs.html" with tab='children' %}
<hr>
<h4>{% trans "Child Stock Items" %}</h4>
{% if item.child_count > 0 %}
{% include "stock_table.html" %}
{% else %}
<div class='alert alert-block alert-info'>
{% trans "This stock item does not have any child items" %}
</div>
{% 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 %}

View File

@ -4,6 +4,9 @@
<li{% ifequal tab 'tracking' %} class='active'{% endifequal %}>
<a href="{% url 'stock-item-detail' item.id %}">{% trans "Tracking" %}</a>
</li>
<li{% ifequal tab 'children' %} class='active'{% endifequal %}>
<a href="{% url 'stock-item-children' item.id %}">{% trans "Children" %}{% if item.child_count > 0 %}<span class='badge'>{{ item.child_count }}</span>{% endif %}</a>
</li>
{% if 0 %}
<!-- These tabs are to be implemented in the future -->
<li{% ifequal tab 'builds' %} class='active'{% endifequal %}>

View File

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

View File

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

View File

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

View File

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