Merge branch 'inventree:master' into matmair/issue2279

This commit is contained in:
Matthias Mair 2022-03-14 23:14:29 +01:00 committed by GitHub
commit 4100834ce9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 19267 additions and 31473 deletions

View File

@ -43,14 +43,12 @@ class Command(BaseCommand):
try:
model.image.render_variations(replace=False)
except FileNotFoundError:
logger.error(f"ERROR: Image file '{img}' is missing")
logger.warning(f"Warning: Image file '{img}' is missing")
except UnidentifiedImageError:
logger.error(f"ERROR: Image file '{img}' is not a valid image")
logger.warning(f"Warning: Image file '{img}' is not a valid image")
def handle(self, *args, **kwargs):
logger.setLevel(logging.INFO)
logger.info("Rebuilding Part thumbnails")
for part in Part.objects.exclude(image=None):

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,8 @@
/*!
* chartjs-adapter-moment v1.0.0
* https://www.chartjs.org
* (c) 2021 chartjs-adapter-moment Contributors
* Released under the MIT license
*/
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(require("moment"),require("chart.js")):"function"==typeof define&&define.amd?define(["moment","chart.js"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).moment,e.Chart)}(this,(function(e,t){"use strict";function n(e){return e&&"object"==typeof e&&"default"in e?e:{default:e}}var f=n(e);const a={datetime:"MMM D, YYYY, h:mm:ss a",millisecond:"h:mm:ss.SSS a",second:"h:mm:ss a",minute:"h:mm a",hour:"hA",day:"MMM D",week:"ll",month:"MMM YYYY",quarter:"[Q]Q - YYYY",year:"YYYY"};t._adapters._date.override("function"==typeof f.default?{_id:"moment",formats:function(){return a},parse:function(e,t){return"string"==typeof e&&"string"==typeof t?e=f.default(e,t):e instanceof f.default||(e=f.default(e)),e.isValid()?e.valueOf():null},format:function(e,t){return f.default(e).format(t)},add:function(e,t,n){return f.default(e).add(t,n).valueOf()},diff:function(e,t,n){return f.default(e).diff(f.default(t),n)},startOf:function(e,t,n){return e=f.default(e),"isoWeek"===t?(n=Math.trunc(Math.min(Math.max(0,n),6)),e.isoWeekday(n).startOf("day").valueOf()):e.startOf(t).valueOf()},endOf:function(e,t){return f.default(e).endOf(t).valueOf()}}:{})}));
//# sourceMappingURL=chartjs-adapter-moment.min.js.map

File diff suppressed because one or more lines are too long

View File

@ -12,11 +12,18 @@ import common.models
INVENTREE_SW_VERSION = "0.7.0 dev"
# InvenTree API version
INVENTREE_API_VERSION = 28
INVENTREE_API_VERSION = 30
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v30 -> 2022-03-09
- Adds "exclude_location" field to BuildAutoAllocation API endpoint
- Allows BuildItem API endpoint to be filtered by BomItem relation
v29 -> 2022-03-08
- Adds "scheduling" endpoint for predicted stock scheduling information
v28 -> 2022-03-04
- Adds an API endpoint for auto allocation of stock items against a build order
- Ref: https://github.com/inventree/InvenTree/pull/2713

View File

@ -461,6 +461,7 @@ class BuildItemList(generics.ListCreateAPIView):
filter_fields = [
'build',
'stock_item',
'bom_item',
'install_into',
]

View File

@ -715,7 +715,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
build=self,
bom_item=bom_item,
stock_item=stock_item,
quantity=quantity,
quantity=1,
install_into=output,
)
@ -842,6 +842,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
"""
location = kwargs.get('location', None)
exclude_location = kwargs.get('exclude_location', None)
interchangeable = kwargs.get('interchangeable', False)
substitutes = kwargs.get('substitutes', True)
@ -875,6 +876,11 @@ class Build(MPTTModel, ReferenceIndexingMixin):
sublocations = location.get_descendants(include_self=True)
available_stock = available_stock.filter(location__in=[loc for loc in sublocations])
if exclude_location:
# Exclude any stock items from the provided location
sublocations = exclude_location.get_descendants(include_self=True)
available_stock = available_stock.exclude(location__in=[loc for loc in sublocations])
"""
Next, we sort the available stock items with the following priority:
1. Direct part matches (+1)

View File

@ -717,6 +717,7 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
class Meta:
fields = [
'location',
'exclude_location',
'interchangeable',
'substitutes',
]
@ -730,6 +731,15 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
help_text=_('Stock location where parts are to be sourced (leave blank to take from any location)'),
)
exclude_location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False,
allow_null=True,
required=False,
label=_('Exclude Location'),
help_text=_('Exclude stock items from this selected location'),
)
interchangeable = serializers.BooleanField(
default=False,
label=_('Interchangeable Stock'),
@ -750,6 +760,7 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
build.auto_allocate_stock(
location=data.get('location', None),
exclude_location=data.get('exclude_location', None),
interchangeable=data['interchangeable'],
substitutes=data['substitutes'],
)

View File

@ -323,30 +323,78 @@
{% block js_ready %}
{{ block.super }}
$('#btn-create-output').click(function() {
onPanelLoad('completed', function() {
loadStockTable($("#build-stock-table"), {
params: {
location_detail: true,
part_detail: true,
build: {{ build.id }},
is_building: false,
},
groupByField: 'location',
buttons: [
'#stock-options',
],
url: "{% url 'api-stock-list' %}",
});
});
createBuildOutput(
{{ build.pk }},
onPanelLoad('children', function() {
loadBuildTable($('#sub-build-table'), {
url: '{% url "api-build-list" %}',
filterTarget: "#filter-list-sub-build",
params: {
ancestor: {{ build.pk }},
}
});
});
onPanelLoad('attachments', function() {
enableDragAndDrop(
'#attachment-dropzone',
'{% url "api-build-attachment-list" %}',
{
trackable_parts: {% if build.part.has_trackable_parts %}true{% else %}false{% endif%},
data: {
build: {{ build.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
location.reload();
}
}
);
loadAttachmentTable('{% url "api-build-attachment-list" %}', {
filters: {
build: {{ build.pk }},
},
fields: {
build: {
value: {{ build.pk }},
hidden: true,
}
}
});
});
loadStockTable($("#build-stock-table"), {
params: {
location_detail: true,
part_detail: true,
build: {{ build.id }},
is_building: false,
},
groupByField: 'location',
buttons: [
'#stock-options',
],
url: "{% url 'api-stock-list' %}",
onPanelLoad('notes', function() {
$('#edit-notes').click(function() {
constructForm('{% url "api-build-detail" build.pk %}', {
fields: {
notes: {
multiline: true,
}
},
title: '{% trans "Edit Notes" %}',
reload: true,
});
});
});
function reloadTable() {
$('#allocation-table-untracked').bootstrapTable('refresh');
}
// Get the list of BOM items required for this build
inventreeGet(
@ -436,57 +484,16 @@ inventreeGet(
}
);
loadBuildTable($('#sub-build-table'), {
url: '{% url "api-build-list" %}',
filterTarget: "#filter-list-sub-build",
params: {
ancestor: {{ build.pk }},
}
});
$('#btn-create-output').click(function() {
enableDragAndDrop(
'#attachment-dropzone',
'{% url "api-build-attachment-list" %}',
{
data: {
build: {{ build.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
location.reload();
createBuildOutput(
{{ build.pk }},
{
trackable_parts: {% if build.part.has_trackable_parts %}true{% else %}false{% endif%},
}
}
);
loadAttachmentTable('{% url "api-build-attachment-list" %}', {
filters: {
build: {{ build.pk }},
},
fields: {
build: {
value: {{ build.pk }},
hidden: true,
}
}
);
});
$('#edit-notes').click(function() {
constructForm('{% url "api-build-detail" build.pk %}', {
fields: {
notes: {
multiline: true,
}
},
title: '{% trans "Edit Notes" %}',
reload: true,
});
});
function reloadTable() {
$('#allocation-table-untracked').bootstrapTable('refresh');
}
{% if build.active %}
$("#btn-auto-allocate").on('click', function() {
@ -578,5 +585,4 @@ $("#btn-order-parts").click(function() {
enableSidebar('buildorder');
{% endblock %}

View File

@ -9,10 +9,10 @@
{% include "sidebar_item.html" with label='allocate' text=text icon="fa-tasks" %}
{% endif %}
{% if not build.is_complete %}
{% trans "Pending Items" as text %}
{% trans "Incomplete Outputs" as text %}
{% include "sidebar_item.html" with label='outputs' text=text icon="fa-tools" %}
{% endif %}
{% trans "Completed Items" as text %}
{% trans "Completed Outputs" as text %}
{% include "sidebar_item.html" with label='completed' text=text icon="fa-boxes" %}
{% trans "Child Build Orders" as text %}
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}

View File

@ -23,7 +23,7 @@ class CommonConfig(AppConfig):
try:
import common.models
if common.models.InvenTreeSetting.get_setting('SERVER_RESTART_REQUIRED'):
if common.models.InvenTreeSetting.get_setting('SERVER_RESTART_REQUIRED', backup_value=False, create=False):
logger.info("Clearing SERVER_RESTART_REQUIRED flag")
common.models.InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', False, None)
except:

View File

@ -273,20 +273,24 @@ class BaseInvenTreeSetting(models.Model):
# Setting does not exist! (Try to create it)
if not setting:
# Attempt to create a new settings object
setting = cls(
key=key,
value=cls.get_setting_default(key, **kwargs),
**kwargs
)
# Unless otherwise specified, attempt to create the setting
create = kwargs.get('create', True)
try:
# Wrap this statement in "atomic", so it can be rolled back if it fails
with transaction.atomic():
setting.save()
except (IntegrityError, OperationalError):
# It might be the case that the database isn't created yet
pass
if create:
# Attempt to create a new settings object
setting = cls(
key=key,
value=cls.get_setting_default(key, **kwargs),
**kwargs
)
try:
# Wrap this statement in "atomic", so it can be rolled back if it fails
with transaction.atomic():
setting.save()
except (IntegrityError, OperationalError):
# It might be the case that the database isn't created yet
pass
return setting
@ -1258,7 +1262,14 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
('MM/DD/YYYY', '02/22/2022'),
('MMM DD YYYY', 'Feb 22 2022'),
]
}
},
'DISPLAY_SCHEDULE_TAB': {
'name': _('Part Scheduling'),
'description': _('Display part scheduling information'),
'default': True,
'validator': bool,
},
}
class Meta:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -50,7 +50,7 @@
</div>
{% if order.status == PurchaseOrderStatus.PENDING %}
<button type='button' class='btn btn-outline-secondary' id='place-order' title='{% trans "Place order" %}'>
<span class='fas fa-shopping-cart icon-blue'></span>
<span class='fas fa-paper-plane icon-blue'></span>
</button>
{% elif order.status == PurchaseOrderStatus.PLACED %}
<button type='button' class='btn btn-primary' id='receive-order' title='{% trans "Receive items" %}'>

View File

@ -5,6 +5,8 @@ Provides a JSON API for the Part app
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import datetime
from django.conf.urls import url, include
from django.http import JsonResponse
from django.db.models import Q, F, Count, Min, Max, Avg
@ -40,7 +42,8 @@ from company.models import Company, ManufacturerPart, SupplierPart
from stock.models import StockItem, StockLocation
from common.models import InvenTreeSetting
from build.models import Build
from build.models import Build, BuildItem
import order.models
from . import serializers as part_serializers
@ -48,7 +51,7 @@ from InvenTree.helpers import str2bool, isNull, increment
from InvenTree.helpers import DownloadFile
from InvenTree.api import AttachmentMixin
from InvenTree.status_codes import BuildStatus
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
class CategoryList(generics.ListCreateAPIView):
@ -430,6 +433,142 @@ class PartThumbsUpdate(generics.RetrieveUpdateAPIView):
]
class PartScheduling(generics.RetrieveAPIView):
"""
API endpoint for delivering "scheduling" information about a given part via the API.
Returns a chronologically ordered list about future "scheduled" events,
concerning stock levels for the part:
- Purchase Orders (incoming stock)
- Sales Orders (outgoing stock)
- Build Orders (incoming completed stock)
- Build Orders (outgoing allocated stock)
"""
queryset = Part.objects.all()
def retrieve(self, request, *args, **kwargs):
today = datetime.datetime.now().date()
part = self.get_object()
schedule = []
def add_schedule_entry(date, quantity, title, label, url):
"""
Check if a scheduled entry should be added:
- date must be non-null
- date cannot be in the "past"
- quantity must not be zero
"""
if date and date >= today and quantity != 0:
schedule.append({
'date': date,
'quantity': quantity,
'title': title,
'label': label,
'url': url,
})
# Add purchase order (incoming stock) information
po_lines = order.models.PurchaseOrderLineItem.objects.filter(
part__part=part,
order__status__in=PurchaseOrderStatus.OPEN,
)
for line in po_lines:
target_date = line.target_date or line.order.target_date
quantity = max(line.quantity - line.received, 0)
add_schedule_entry(
target_date,
quantity,
_('Incoming Purchase Order'),
str(line.order),
line.order.get_absolute_url()
)
# Add sales order (outgoing stock) information
so_lines = order.models.SalesOrderLineItem.objects.filter(
part=part,
order__status__in=SalesOrderStatus.OPEN,
)
for line in so_lines:
target_date = line.target_date or line.order.target_date
quantity = max(line.quantity - line.shipped, 0)
add_schedule_entry(
target_date,
-quantity,
_('Outgoing Sales Order'),
str(line.order),
line.order.get_absolute_url(),
)
# Add build orders (incoming stock) information
build_orders = Build.objects.filter(
part=part,
status__in=BuildStatus.ACTIVE_CODES
)
for build in build_orders:
quantity = max(build.quantity - build.completed, 0)
add_schedule_entry(
build.target_date,
quantity,
_('Stock produced by Build Order'),
str(build),
build.get_absolute_url(),
)
"""
Add build order allocation (outgoing stock) information.
Here we need some careful consideration:
- 'Tracked' stock items are removed from stock when the individual Build Output is completed
- 'Untracked' stock items are removed from stock when the Build Order is completed
The 'simplest' approach here is to look at existing BuildItem allocations which reference this part,
and "schedule" them for removal at the time of build order completion.
This assumes that the user is responsible for correctly allocating parts.
However, it has the added benefit of side-stepping the various BOM substition options,
and just looking at what stock items the user has actually allocated against the Build.
"""
build_allocations = BuildItem.objects.filter(
stock_item__part=part,
build__status__in=BuildStatus.ACTIVE_CODES,
)
for allocation in build_allocations:
add_schedule_entry(
allocation.build.target_date,
-allocation.quantity,
_('Stock required for Build Order'),
str(allocation.build),
allocation.build.get_absolute_url(),
)
# Sort by incrementing date values
schedule = sorted(schedule, key=lambda entry: entry['date'])
return Response(schedule)
class PartSerialNumberDetail(generics.RetrieveAPIView):
"""
API endpoint for returning extra serial number information about a particular part
@ -1734,6 +1873,9 @@ part_api_urls = [
# Endpoint for extra serial number information
url(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'),
# Endpoint for future scheduling information
url(r'^scheduling/', PartScheduling.as_view(), name='api-part-scheduling'),
# Endpoint for duplicating a BOM for the specific Part
url(r'^bom-copy/', PartCopyBOM.as_view(), name='api-part-bom-copy'),

View File

@ -20,7 +20,7 @@ from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator
from django.contrib.auth.models import User
from django.db.models.signals import pre_delete, post_save
from django.db.models.signals import post_save
from django.dispatch import receiver
from jinja2 import Template
@ -76,6 +76,35 @@ class PartCategory(InvenTreeTree):
default_keywords: Default keywords for parts created in this category
"""
def delete(self, *args, **kwargs):
"""
Custom model deletion routine, which updates any child categories or parts.
This must be handled within a transaction.atomic(), otherwise the tree structure is damaged
"""
with transaction.atomic():
parent = self.parent
tree_id = self.tree_id
# Update each part in this category to point to the parent category
for part in self.parts.all():
part.category = self.parent
part.save()
# Update each child category
for child in self.children.all():
child.parent = self.parent
child.save()
super().delete(*args, **kwargs)
if parent is not None:
# Partially rebuild the tree (cheaper than a complete rebuild)
PartCategory.objects.partial_rebuild(tree_id)
else:
PartCategory.objects.rebuild()
default_location = TreeForeignKey(
'stock.StockLocation', related_name="default_categories",
null=True, blank=True,
@ -260,27 +289,6 @@ class PartCategory(InvenTreeTree):
).delete()
@receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
def before_delete_part_category(sender, instance, using, **kwargs):
""" Receives before_delete signal for PartCategory object
Before deleting, update child Part and PartCategory objects:
- For each child category, set the parent to the parent of *this* category
- For each part, set the 'category' to the parent of *this* category
"""
# Update each part in this category to point to the parent category
for part in instance.parts.all():
part.category = instance.parent
part.save()
# Update each child category
for child in instance.children.all():
child.parent = instance.parent
child.save()
def rename_part_image(instance, filename):
""" Function for renaming a part image file

View File

@ -2,38 +2,31 @@
{% load i18n %}
{% block pre_form_content %}
{% trans 'Are you sure you want to delete category' %} <strong>{{ category.name }}</strong>?
<div class='alert alert-block alert-danger'>
{% trans "Are you sure you want to delete this part category?" %}
</div>
{% if category.children.all|length > 0 %}
<p>{% blocktrans with count=category.children.all|length%}This category contains {{count}} child categories{% endblocktrans %}.<br>
{% trans 'If this category is deleted, these child categories will be moved to the' %}
{% if category.parent %}
<strong>{{ category.parent.name }}</strong> {% trans 'category' %}.
{% else %}
{% trans 'top level Parts category' %}.
{% endif %}
</p>
<ul class='list-group'>
{% for cat in category.children.all %}
<li class='list-group-item'><strong>{{ cat.name }}</strong> - <em>{{ cat.description }}</em></li>
{% endfor %}
</ul>
<div class='alert alert-block alert-warning'>
{% blocktrans with n=category.children.all|length %}This category contains {{ n }} child categories{% endblocktrans %}.<br>
{% if category.parent %}
{% blocktrans with category=category.parent.name %}If this category is deleted, these child categories will be moved to {{ category }}{% endblocktrans %}.
{% else %}
{% trans "If this category is deleted, these child categories will be moved to the top level part category" %}.
{% endif %}
</div>
{% endif %}
{% if category.parts.all|length > 0 %}
<p>{% blocktrans with count=category.parts.all|length %}This category contains {{count}} parts{% endblocktrans %}.<br>
<div class='alert alert-block alert-warning'>
{% blocktrans with n=category.parts.all|length %}This category contains {{ n }} parts{% endblocktrans %}.<br>
{% if category.parent %}
{% blocktrans with path=category.parent.pathstring %}If this category is deleted, these parts will be moved to the parent category {{path}}{% endblocktrans %}
{% blocktrans with category=category.parent.name %}If this category is deleted, these parts will be moved to {{ category }}{% endblocktrans %}.
{% else %}
{% trans 'If this category is deleted, these parts will be moved to the top-level category Teile' %}
{% trans "If this category is deleted, these parts will be moved to the top level part category" %}.
{% endif %}
</p>
<ul class='list-group'>
{% for part in category.parts.all %}
<li class='list-group-item'><strong>{{ part.full_name }}</strong> - <em>{{ part.description }}</em></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock %}

View File

@ -32,6 +32,21 @@
</div>
</div>
{% settings_value 'DISPLAY_SCHEDULE_TAB' user=request.user as show_scheduling %}
{% if show_scheduling %}
<div class='panel panel-hidden' id='panel-scheduling'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Part Scheduling" %}</h4>
{% include "spacer.html" %}
</div>
</div>
<div class='panel-content'>
{% include "part/part_scheduling.html" %}
</div>
</div>
{% endif %}
<div class='panel panel-hidden' id='panel-allocations'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
@ -417,6 +432,11 @@
{% block js_ready %}
{{ block.super }}
// Load the "scheduling" tab
onPanelLoad('scheduling', function() {
loadPartSchedulingChart('part-schedule-chart', {{ part.pk }});
});
// Load the "suppliers" tab
onPanelLoad('suppliers', function() {

View File

@ -0,0 +1,6 @@
{% load i18n %}
{% load inventree_extras %}
<div id='part-schedule' style='max-height: 300px;'>
<canvas id='part-schedule-chart' width='100%' style='max-height: 300px;'></canvas>
</div>

View File

@ -44,6 +44,11 @@
{% trans "Sales Orders" as text %}
{% include "sidebar_item.html" with label="sales-orders" text=text icon="fa-truck" %}
{% endif %}
{% settings_value 'DISPLAY_SCHEDULE_TAB' user=request.user as show_scheduling %}
{% if show_scheduling %}
{% trans "Scheduling" as text %}
{% include "sidebar_item.html" with label="scheduling" text=text icon="fa-calendar-alt" %}
{% endif %}
{% if part.trackable %}
{% trans "Test Templates" as text %}
{% include "sidebar_item.html" with label="test-templates" text=text icon="fa-vial" %}

View File

@ -172,3 +172,122 @@ class CategoryTest(TestCase):
# And one part should have no default location at all
w = Part.objects.get(name='Widget')
self.assertIsNone(w.get_default_location())
def test_category_tree(self):
"""
Unit tests for the part category tree structure (MPTT)
Ensure that the MPTT structure is rebuilt correctly,
and the correct ancestor tree is observed.
"""
# Clear out any existing parts
Part.objects.all().delete()
# First, create a structured tree of part categories
A = PartCategory.objects.create(
name='A',
description='Top level category',
)
B1 = PartCategory.objects.create(name='B1', parent=A)
B2 = PartCategory.objects.create(name='B2', parent=A)
B3 = PartCategory.objects.create(name='B3', parent=A)
C11 = PartCategory.objects.create(name='C11', parent=B1)
C12 = PartCategory.objects.create(name='C12', parent=B1)
C13 = PartCategory.objects.create(name='C13', parent=B1)
C21 = PartCategory.objects.create(name='C21', parent=B2)
C22 = PartCategory.objects.create(name='C22', parent=B2)
C23 = PartCategory.objects.create(name='C23', parent=B2)
C31 = PartCategory.objects.create(name='C31', parent=B3)
C32 = PartCategory.objects.create(name='C32', parent=B3)
C33 = PartCategory.objects.create(name='C33', parent=B3)
# Check that the tree_id value is correct
for cat in [B1, B2, B3, C11, C22, C33]:
self.assertEqual(cat.tree_id, A.tree_id)
self.assertEqual(cat.level, cat.parent.level + 1)
self.assertEqual(cat.get_ancestors().count(), cat.level)
# Spot check for C31
ancestors = C31.get_ancestors(include_self=True)
self.assertEqual(ancestors.count(), 3)
self.assertEqual(ancestors[0], A)
self.assertEqual(ancestors[1], B3)
self.assertEqual(ancestors[2], C31)
# At this point, we are confident that the tree is correctly structured
# Add some parts to category B3
for i in range(10):
Part.objects.create(
name=f'Part {i}',
description='A test part',
category=B3,
)
self.assertEqual(Part.objects.filter(category=B3).count(), 10)
self.assertEqual(Part.objects.filter(category=A).count(), 0)
# Delete category B3
B3.delete()
# Child parts have been moved to category A
self.assertEqual(Part.objects.filter(category=A).count(), 10)
for cat in [C31, C32, C33]:
# These categories should now be directly under A
cat.refresh_from_db()
self.assertEqual(cat.parent, A)
self.assertEqual(cat.level, 1)
self.assertEqual(cat.get_ancestors().count(), 1)
self.assertEqual(cat.get_ancestors()[0], A)
# Now, delete category A
A.delete()
# Parts have now been moved to the top-level category
self.assertEqual(Part.objects.filter(category=None).count(), 10)
for loc in [B1, B2, C31, C32, C33]:
# These should now all be "top level" categories
loc.refresh_from_db()
self.assertEqual(loc.level, 0)
self.assertEqual(loc.parent, None)
# Check descendants for B1
descendants = B1.get_descendants()
self.assertEqual(descendants.count(), 3)
for loc in [C11, C12, C13]:
self.assertTrue(loc in descendants)
# Check category C1x, should be B1 -> C1x
for loc in [C11, C12, C13]:
loc.refresh_from_db()
self.assertEqual(loc.level, 1)
self.assertEqual(loc.parent, B1)
ancestors = loc.get_ancestors(include_self=True)
self.assertEqual(ancestors.count(), 2)
self.assertEqual(ancestors[0], B1)
self.assertEqual(ancestors[1], loc)
# Check category C2x, should be B2 -> C2x
for loc in [C21, C22, C23]:
loc.refresh_from_db()
self.assertEqual(loc.level, 1)
self.assertEqual(loc.parent, B2)
ancestors = loc.get_ancestors(include_self=True)
self.assertEqual(ancestors.count(), 2)
self.assertEqual(ancestors[0], B2)
self.assertEqual(ancestors[1], loc)

View File

@ -54,6 +54,35 @@ class StockLocation(InvenTreeTree):
Stock locations can be heirarchical as required
"""
def delete(self, *args, **kwargs):
"""
Custom model deletion routine, which updates any child locations or items.
This must be handled within a transaction.atomic(), otherwise the tree structure is damaged
"""
with transaction.atomic():
parent = self.parent
tree_id = self.tree_id
# Update each stock item in the stock location
for item in self.stock_items.all():
item.location = self.parent
item.save()
# Update each child category
for child in self.children.all():
child.parent = self.parent
child.save()
super().delete(*args, **kwargs)
if parent is not None:
# Partially rebuild the tree (cheaper than a complete rebuild)
StockLocation.objects.partial_rebuild(tree_id)
else:
StockLocation.objects.rebuild()
@staticmethod
def get_api_url():
return reverse('api-location-list')
@ -159,20 +188,6 @@ class StockLocation(InvenTreeTree):
return self.stock_item_count()
@receiver(pre_delete, sender=StockLocation, dispatch_uid='stocklocation_delete_log')
def before_delete_stock_location(sender, instance, using, **kwargs):
# Update each part in the stock location
for item in instance.stock_items.all():
item.location = instance.parent
item.save()
# Update each child category
for child in instance.children.all():
child.parent = instance.parent
child.save()
class StockItemManager(TreeManager):
"""
Custom database manager for the StockItem class.

View File

@ -462,9 +462,7 @@ $("#print-label").click(function() {
{% if roles.stock.change %}
$("#stock-duplicate").click(function() {
// Duplicate a stock item
duplicateStockItem({{ item.pk }}, {
follow: true,
});
duplicateStockItem({{ item.pk }}, {});
});
$('#stock-edit').click(function() {

View File

@ -4,40 +4,31 @@
{% load inventree_extras %}
{% block pre_form_content %}
{% trans "Are you sure you want to delete this stock location?" %}
<br>
<div class='alert alert-block alert-danger'>
{% trans "Are you sure you want to delete this stock location?" %}
</div>
{% if location.children.all|length > 0 %}
<p>This location contains {{ location.children.all|length }} child locations.<br>
If this location is deleted, these child locations will be moved to
{% if location.parent %}
the '{{ location.parent.name }}' location.
{% else %}
the top level 'Stock' location.
<div class='alert alert-block alert-warning'>
{% blocktrans with n=location.children.all|length %}This location contains {{ n }} child locations{% endblocktrans %}.<br>
{% if location.parent %}
{% blocktrans with location=location.parent.name %}If this location is deleted, these child locations will be moved to {{ location }}{% endblocktrans %}.
{% else %}
{% trans "If this location is deleted, these child locations will be moved to the top level stock location" %}.
{% endif %}
</div>
{% endif %}
</p>
<ul class='list-group'>
{% for loc in location.children.all %}
<li class='list-group-item'><strong>{{ loc.name }}</strong> - <em>{{ loc.description}}</em></li>
{% endfor %}
</ul>
{% endif %}
{% if location.stock_items.all|length > 0 %}
<p>This location contains {{ location.stock_items.all|length }} stock items.<br>
{% if location.parent %}
If this location is deleted, these items will be moved to the '{{ location.parent.name }}' location.
{% else %}
If this location is deleted, these items will be moved to the top level 'Stock' location.
<div class='alert alert-block alert-warning'>
{% blocktrans with n=location.stock_items.all|length %}This location contains {{ n }} stock items{% endblocktrans %}.<br>
{% if location.parent %}
{% blocktrans with location=location.parent.name %}If this location is deleted, these stock items will be moved to {{ location }}{% endblocktrans %}.
{% else %}
{% trans "If this location is deleted, these stock items will be moved to the top level stock location" %}.
{% endif %}
</div>
{% endif %}
</p>
<ul class='list-group'>
{% for item in location.stock_items.all %}
<li class='list-group-item'><strong>{{ item.part.full_name }}</strong> - <em>{{ item.part.description }}</em><span class='badge badge-right rounded-pill bg-dark'>{% decimal item.quantity %}</span></li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View File

@ -524,6 +524,174 @@ class StockTest(TestCase):
# Serialize the remainder of the stock
item.serializeStock(2, [99, 100], self.user)
def test_location_tree(self):
"""
Unit tests for stock location tree structure (MPTT).
Ensure that the MPTT structure is rebuilt correctly,
and the corrent ancestor tree is observed.
Ref: https://github.com/inventree/InvenTree/issues/2636
Ref: https://github.com/inventree/InvenTree/issues/2733
"""
# First, we will create a stock location structure
A = StockLocation.objects.create(
name='A',
description='Top level location'
)
B1 = StockLocation.objects.create(
name='B1',
parent=A
)
B2 = StockLocation.objects.create(
name='B2',
parent=A
)
B3 = StockLocation.objects.create(
name='B3',
parent=A
)
C11 = StockLocation.objects.create(
name='C11',
parent=B1,
)
C12 = StockLocation.objects.create(
name='C12',
parent=B1,
)
C21 = StockLocation.objects.create(
name='C21',
parent=B2,
)
C22 = StockLocation.objects.create(
name='C22',
parent=B2,
)
C31 = StockLocation.objects.create(
name='C31',
parent=B3,
)
C32 = StockLocation.objects.create(
name='C32',
parent=B3
)
# Check that the tree_id is correct for each sublocation
for loc in [B1, B2, B3, C11, C12, C21, C22, C31, C32]:
self.assertEqual(loc.tree_id, A.tree_id)
# Check that the tree levels are correct for each node in the tree
self.assertEqual(A.level, 0)
self.assertEqual(A.get_ancestors().count(), 0)
for loc in [B1, B2, B3]:
self.assertEqual(loc.parent, A)
self.assertEqual(loc.level, 1)
self.assertEqual(loc.get_ancestors().count(), 1)
for loc in [C11, C12]:
self.assertEqual(loc.parent, B1)
self.assertEqual(loc.level, 2)
self.assertEqual(loc.get_ancestors().count(), 2)
for loc in [C21, C22]:
self.assertEqual(loc.parent, B2)
self.assertEqual(loc.level, 2)
self.assertEqual(loc.get_ancestors().count(), 2)
for loc in [C31, C32]:
self.assertEqual(loc.parent, B3)
self.assertEqual(loc.level, 2)
self.assertEqual(loc.get_ancestors().count(), 2)
# Spot-check for C32
ancestors = C32.get_ancestors(include_self=True)
self.assertEqual(ancestors[0], A)
self.assertEqual(ancestors[1], B3)
self.assertEqual(ancestors[2], C32)
# At this point, we are confident that the tree is correctly structured.
# Let's delete node B3 from the tree. We expect that:
# - C31 should move directly under A
# - C32 should move directly under A
# Add some stock items to B3
for i in range(10):
StockItem.objects.create(
part=Part.objects.get(pk=1),
quantity=10,
location=B3
)
self.assertEqual(StockItem.objects.filter(location=B3).count(), 10)
self.assertEqual(StockItem.objects.filter(location=A).count(), 0)
B3.delete()
A.refresh_from_db()
C31.refresh_from_db()
C32.refresh_from_db()
# Stock items have been moved to A
self.assertEqual(StockItem.objects.filter(location=A).count(), 10)
# Parent should be A
self.assertEqual(C31.parent, A)
self.assertEqual(C32.parent, A)
self.assertEqual(C31.tree_id, A.tree_id)
self.assertEqual(C31.level, 1)
self.assertEqual(C32.tree_id, A.tree_id)
self.assertEqual(C32.level, 1)
# Ancestor tree should be just A
ancestors = C31.get_ancestors()
self.assertEqual(ancestors.count(), 1)
self.assertEqual(ancestors[0], A)
ancestors = C32.get_ancestors()
self.assertEqual(ancestors.count(), 1)
self.assertEqual(ancestors[0], A)
# Delete A
A.delete()
# Stock items have been moved to top-level location
self.assertEqual(StockItem.objects.filter(location=None).count(), 10)
for loc in [B1, B2, C11, C12, C21, C22]:
loc.refresh_from_db()
self.assertEqual(B1.parent, None)
self.assertEqual(B2.parent, None)
self.assertEqual(C11.parent, B1)
self.assertEqual(C12.parent, B1)
self.assertEqual(C11.get_ancestors().count(), 1)
self.assertEqual(C12.get_ancestors().count(), 1)
self.assertEqual(C21.parent, B2)
self.assertEqual(C22.parent, B2)
ancestors = C21.get_ancestors()
self.assertEqual(C21.get_ancestors().count(), 1)
self.assertEqual(C22.get_ancestors().count(), 1)
class VariantTest(StockTest):
"""

View File

@ -18,6 +18,7 @@
{% include "InvenTree/settings/setting.html" with key="DATE_DISPLAY_FORMAT" icon="fa-calendar-alt" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="FORMS_CLOSE_USING_ESCAPE" icon="fa-window-close" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="DISPLAY_SCHEDULE_TAB" icon="fa-calendar-alt" user_setting=True %}
</tbody>
</table>
</div>

View File

@ -154,8 +154,10 @@
<script type="text/javascript" src="{% static 'fullcalendar/main.js' %}"></script>
<script type="text/javascript" src="{% static 'fullcalendar/locales-all.js' %}"></script>
<script type="text/javascript" src="{% static 'select2/js/select2.full.js' %}"></script>
<script type='text/javascript' src="{% static 'script/chart.js' %}"></script>
<script type='text/javascript' src="{% static 'script/moment.js' %}"></script>
<script type='text/javascript' src="{% static 'script/chart.min.js' %}"></script>
<script type='text/javascript' src="{% static 'script/chartjs-adapter-moment.js' %}"></script>
<script type='text/javascript' src="{% static 'script/clipboard.min.js' %}"></script>
<script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script>

View File

@ -1219,6 +1219,18 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
$(table).bootstrapTable('updateByUniqueId', key, tableRow, true);
}
// Update any rows which we did not receive allocation information for
var td = $(table).bootstrapTable('getData');
td.forEach(function(tableRow) {
if (tableRow.allocations == null) {
tableRow.allocations = [];
$(table).bootstrapTable('updateByUniqueId', tableRow.pk, tableRow, true);
}
});
// Update the progress bar for this build output
var build_progress = $(`#output-progress-${outputId}`);
@ -1419,15 +1431,17 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
formatter: function(value, row) {
var allocated = 0;
if (row.allocations) {
if (row.allocations != null) {
row.allocations.forEach(function(item) {
allocated += item.quantity;
});
var required = requiredQuantity(row);
return makeProgressBar(allocated, required);
} else {
return `<em>{% trans "loading" %}...</em><span class='fas fa-spinner fa-spin float-right'></span>`;
}
var required = requiredQuantity(row);
return makeProgressBar(allocated, required);
},
sorter: function(valA, valB, rowA, rowB) {
// Custom sorting function for progress bars
@ -1876,6 +1890,7 @@ function autoAllocateStockToBuild(build_id, bom_items=[], options={}) {
location: {
value: options.location,
},
exclude_location: {},
interchangeable: {
value: true,
},

View File

@ -33,6 +33,7 @@
loadPartPurchaseOrderTable,
loadPartTable,
loadPartTestTemplateTable,
loadPartSchedulingChart,
loadPartVariantTable,
loadRelatedPartsTable,
loadSellPricingChart,
@ -1981,6 +1982,123 @@ function initPriceBreakSet(table, options) {
}
function loadPartSchedulingChart(canvas_id, part_id) {
var part_info = null;
// First, grab updated data for the particular part
inventreeGet(`/api/part/${part_id}/`, {}, {
async: false,
success: function(response) {
part_info = response;
}
});
var today = moment();
// Create an initial entry, using the available quantity
var stock_schedule = [
{
date: today,
delta: 0,
label: '{% trans "Current Stock" %}',
}
];
/* Request scheduling information for the part.
* Note that this information has already been 'curated' by the server,
* and arranged in increasing chronological order
*/
inventreeGet(
`/api/part/${part_id}/scheduling/`,
{},
{
async: false,
success: function(response) {
response.forEach(function(entry) {
stock_schedule.push({
date: moment(entry.date),
delta: entry.quantity,
title: entry.title,
label: entry.label,
url: entry.url,
});
});
}
}
);
// Iterate through future "events" to calculate expected quantity
var quantity = part_info.in_stock;
for (var idx = 0; idx < stock_schedule.length; idx++) {
quantity += stock_schedule[idx].delta;
stock_schedule[idx].x = stock_schedule[idx].date.format('YYYY-MM-DD');
stock_schedule[idx].y = quantity;
}
var context = document.getElementById(canvas_id);
const data = {
datasets: [{
label: '{% trans "Scheduled Stock Quantities" %}',
data: stock_schedule,
backgroundColor: 'rgb(220, 160, 80)',
borderWidth: 2,
borderColor: 'rgb(90, 130, 150)'
}],
};
return new Chart(context, {
type: 'scatter',
data: data,
options: {
showLine: true,
stepped: true,
scales: {
x: {
type: 'time',
min: today.format(),
position: 'bottom',
time: {
unit: 'day',
},
},
y: {
beginAtZero: true,
}
},
plugins: {
tooltip: {
callbacks: {
label: function(item) {
return item.raw.label;
},
beforeLabel: function(item) {
return item.raw.title;
},
afterLabel: function(item) {
var delta = item.raw.delta;
if (delta == 0) {
delta = '';
} else {
delta = ` (${item.raw.delta > 0 ? '+' : ''}${item.raw.delta})`;
}
return `{% trans "Quantity" %}: ${item.raw.y}${delta}`;
}
}
}
},
}
});
}
function loadStockPricingChart(context, data) {
return new Chart(context, {
type: 'bar',

View File

@ -294,7 +294,17 @@ function stockItemGroups(options={}) {
*/
function duplicateStockItem(pk, options) {
// First, we need the StockItem informatino
// If no "success" function provided, add a default
if (!options.onSuccess) {
options.onSuccess = function(response) {
showAlertOrCache('{% trans "Stock item duplicated" %}', true, {style: 'success'});
window.location.href = `/stock/item/${response.pk}/`;
};
}
// First, we need the StockItem information
inventreeGet(`/api/stock/${pk}/`, {}, {
success: function(data) {

View File

@ -35,7 +35,7 @@ importlib_metadata # Backport for importlib.metadata
inventree # Install the latest version of the InvenTree API python library
markdown==3.3.4 # Force particular version of markdown
pep8-naming==0.11.1 # PEP naming convention extension
pillow==9.0.0 # Image manipulation
pillow==9.0.1 # Image manipulation
py-moneyed==0.8.0 # Specific version requirement for py-moneyed
pygments==2.7.4 # Syntax highlighting
python-barcode[images]==0.13.1 # Barcode generator