Merge branch 'inventree:master' into matmair/issue2694

This commit is contained in:
Matthias Mair 2022-04-05 02:03:40 +02:00 committed by GitHub
commit 4ab27e5f3c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 20257 additions and 15514 deletions

View File

@ -1018,4 +1018,33 @@ input[type='number']{
a {
text-decoration: none;
background-color: transparent;
}
/* Quicksearch Panel */
.search-result-panel {
max-width: 800px;
width: 75%
}
.search-result-group {
padding: 5px;
padding-left: 10px;
padding-right: 10px;
border: 1px solid var(--border-color);
margin-bottom: 10px;
}
.search-result-group-buttons > button{
padding: 2px;
padding-left: 5px;
padding-right: 5px;
font-size: 80%;
}
.search-result-entry {
border-top: 1px solid var(--border-color);
padding: 3px;
margin-top: 3px;
overflow: hidden;
}

View File

@ -128,81 +128,6 @@ function inventreeDocReady() {
attachClipboard('.clip-btn', 'modal-about');
attachClipboard('.clip-btn-version', 'modal-about', 'about-copy-text');
// Add autocomplete to the search-bar
if ($('#search-bar').exists()) {
$('#search-bar').autocomplete({
source: function(request, response) {
var params = {
search: request.term,
limit: user_settings.SEARCH_PREVIEW_RESULTS,
offset: 0,
};
if (user_settings.SEARCH_HIDE_INACTIVE_PARTS) {
// Limit to active parts
params.active = true;
}
$.ajax({
url: '/api/part/',
data: params,
success: function(data) {
var transformed = $.map(data.results, function(el) {
return {
label: el.full_name,
id: el.pk,
thumbnail: el.thumbnail,
data: el,
};
});
response(transformed);
},
error: function() {
response([]);
}
});
},
create: function() {
$(this).data('ui-autocomplete')._renderItem = function(ul, item) {
var html = `
<div class='search-autocomplete-item' title='${item.data.description}'>
<a href='/part/${item.id}/'>
<span style='padding-right: 10px;'><img class='hover-img-thumb' src='${item.thumbnail || "/static/img/blank_image.png"}'> ${item.label}</span>
</a>
<span class='flex' style='flex-grow: 1;'></span>
`;
if (user_settings.SEARCH_SHOW_STOCK_LEVELS) {
html += partStockLabel(
item.data,
{
classes: 'badge-right',
}
);
}
html += '</div>';
return $('<li>').append(html).appendTo(ul);
};
},
select: function( event, ui ) {
window.location = '/part/' + ui.item.id + '/';
},
minLength: 2,
classes: {
'ui-autocomplete': 'dropdown-menu search-menu',
},
position: {
my : "right top",
at: "right bottom"
}
});
}
// Generate brand-icons
$('.brand-icon').each(function(i, obj) {
loadBrandIcon($(this), $(this).attr('brand_name'));
@ -231,8 +156,13 @@ function inventreeDocReady() {
stopNotificationWatcher();
});
$('#offcanvasRight').on('show.bs.offcanvas', openNotificationPanel); // listener for opening the notification panel
$('#offcanvasRight').on('hidden.bs.offcanvas', closeNotificationPanel); // listener for closing the notification panel
// Calbacks for search panel
$('#offcanvas-search').on('shown.bs.offcanvas', openSearchPanel);
$('#offcanvas-search').on('hidden.bs.offcanvas', closeSearchPanel);
// Callbacks for notifications panel
$('#offcanvas-notification').on('show.bs.offcanvas', openNotificationPanel); // listener for opening the notification panel
$('#offcanvas-notification').on('hidden.bs.offcanvas', closeNotificationPanel); // listener for closing the notification panel
}

View File

@ -72,7 +72,7 @@ class ViewTests(TestCase):
"""
# Change this number as more javascript files are added to the index page
N_SCRIPT_FILES = 37
N_SCRIPT_FILES = 38
content = self.get_index_page()

View File

@ -130,6 +130,7 @@ translated_javascript_urls = [
url(r'^order.js', DynamicJsView.as_view(template_name='js/translated/order.js'), name='order.js'),
url(r'^part.js', DynamicJsView.as_view(template_name='js/translated/part.js'), name='part.js'),
url(r'^report.js', DynamicJsView.as_view(template_name='js/translated/report.js'), name='report.js'),
url(r'^search.js', DynamicJsView.as_view(template_name='js/translated/search.js'), name='search.js'),
url(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'),
url(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'),
url(r'^tables.js', DynamicJsView.as_view(template_name='js/translated/tables.js'), name='tables.js'),

View File

@ -12,11 +12,18 @@ import common.models
INVENTREE_SW_VERSION = "0.7.0 dev"
# InvenTree API version
INVENTREE_API_VERSION = 34
INVENTREE_API_VERSION = 36
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v36 -> 2022-04-03
- Adds ability to filter part list endpoint by unallocated_stock argument
v35 -> 2022-04-01 : https://github.com/inventree/InvenTree/pull/2797
- Adds stock allocation information to the Part API
- Adds calculated field for "unallocated_quantity"
v34 -> 2022-03-25
- Change permissions for "plugin list" API endpoint (now allows any authenticated user)
@ -196,7 +203,7 @@ def isInvenTreeUpToDate():
and stores it to the database as INVENTREE_LATEST_VERSION
"""
latest = common.models.InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION', None)
latest = common.models.InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION', backup_value=None, create=False)
# No record for "latest" version - we must assume we are up to date!
if not latest:

View File

@ -1,5 +1,7 @@
{% extends "modal_delete_form.html" %}
{% load i18n %}
{% block pre_form_content %}
Are you sure you want to delete this build?
{% trans "Are you sure you want to delete this build?" %}
{% endblock %}

View File

@ -1247,6 +1247,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool,
},
'LABEL_ENABLE': {
'name': _('Enable label printing'),
'description': _('Enable label printing from the web interface'),
'default': True,
'validator': bool,
},
"LABEL_INLINE": {
'name': _('Inline label display'),
'description': _('Display PDF labels in the browser, instead of downloading as a file'),
@ -1261,20 +1268,62 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool,
},
'SEARCH_PREVIEW_RESULTS': {
'name': _('Search Preview Results'),
'description': _('Number of results to show in search preview window'),
'default': 10,
'validator': [int, MinValueValidator(1)]
},
'SEARCH_SHOW_STOCK_LEVELS': {
'name': _('Search Show Stock'),
'description': _('Display stock levels in search preview window'),
'SEARCH_PREVIEW_SHOW_PARTS': {
'name': _('Search Parts'),
'description': _('Display parts in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_CATEGORIES': {
'name': _('Search Categories'),
'description': _('Display part categories in search preview window'),
'default': False,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_STOCK': {
'name': _('Search Stock'),
'description': _('Display stock items in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_LOCATIONS': {
'name': _('Search Locations'),
'description': _('Display stock locations in search preview window'),
'default': False,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_COMPANIES': {
'name': _('Search Companies'),
'description': _('Display companies in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS': {
'name': _('Search Purchase Orders'),
'description': _('Display purchase orders in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_SHOW_SALES_ORDERS': {
'name': _('Search Sales Orders'),
'description': _('Display sales orders in search preview window'),
'default': True,
'validator': bool,
},
'SEARCH_PREVIEW_RESULTS': {
'name': _('Search Preview Results'),
'description': _('Number of results to show in each section of the search preview window'),
'default': 10,
'validator': [int, MinValueValidator(1)]
},
'SEARCH_HIDE_INACTIVE_PARTS': {
'name': _("Hide Inactive Parts"),
'description': _('Hide inactive parts in search preview window'),

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

@ -798,6 +798,20 @@ class PartFilter(rest_filters.FilterSet):
return queryset
# unallocated_stock filter
unallocated_stock = rest_filters.BooleanFilter(label='Unallocated stock', method='filter_unallocated_stock')
def filter_unallocated_stock(self, queryset, name, value):
value = str2bool(value)
if value:
queryset = queryset.filter(Q(unallocated_stock__gt=0))
else:
queryset = queryset.filter(Q(unallocated_stock__lte=0))
return queryset
is_template = rest_filters.BooleanFilter()
assembly = rest_filters.BooleanFilter()
@ -1334,6 +1348,7 @@ class PartList(generics.ListCreateAPIView):
'creation_date',
'IPN',
'in_stock',
'unallocated_stock',
'category',
]

View File

@ -38,3 +38,11 @@
part: 1
sub_part: 5
quantity: 3
# Make "Assembly" from "Bob"
- model: part.bomitem
pk: 6
fields:
part: 101
sub_part: 100
quantity: 10

View File

@ -108,6 +108,18 @@
lft: 0
rght: 0
- model: part.part
pk: 101
fields:
name: 'Assembly'
description: 'A high level assembly'
salable: true
active: True
tree_id: 0
level: 0
lft: 0
rght: 0
# A 'template' part
- model: part.part
pk: 10000

View File

@ -1345,7 +1345,8 @@ class Part(MPTTModel):
queryset = OrderModels.SalesOrderAllocation.objects.filter(item__part__id=self.id)
pending = kwargs.get('pending', None)
# Default behaviour is to only return *pending* allocations
pending = kwargs.get('pending', True)
if pending is True:
# Look only for 'open' orders which have not shipped
@ -1433,7 +1434,7 @@ class Part(MPTTModel):
- If this part is a "template" (variants exist) then these are counted too
"""
return self.get_stock_count()
return self.get_stock_count(include_variants=True)
def get_bom_item_filter(self, include_inherited=True):
"""

View File

@ -7,7 +7,7 @@ from decimal import Decimal
from django.urls import reverse_lazy
from django.db import models, transaction
from django.db.models import Q
from django.db.models import ExpressionWrapper, F, Q
from django.db.models.functions import Coalesce
from django.utils.translation import ugettext_lazy as _
@ -24,7 +24,10 @@ from InvenTree.serializers import (DataFileUploadSerializer,
InvenTreeAttachmentSerializer,
InvenTreeMoneySerializer)
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from InvenTree.status_codes import (BuildStatus,
PurchaseOrderStatus,
SalesOrderStatus)
from stock.models import StockItem
from .models import (BomItem, BomItemSubstitute,
@ -363,6 +366,51 @@ class PartSerializer(InvenTreeModelSerializer):
),
)
"""
Annotate with the number of stock items allocated to sales orders.
This annotation is modeled on Part.sales_order_allocations() method:
- Only look for "open" orders
- Stock items have not been "shipped"
"""
so_allocation_filter = Q(
line__order__status__in=SalesOrderStatus.OPEN, # LineItem points to an OPEN order
shipment__shipment_date=None, # Allocated item has *not* been shipped out
)
queryset = queryset.annotate(
allocated_to_sales_orders=Coalesce(
SubquerySum('stock_items__sales_order_allocations__quantity', filter=so_allocation_filter),
Decimal(0),
output_field=models.DecimalField(),
)
)
"""
Annotate with the number of stock items allocated to build orders.
This annotation is modeled on Part.build_order_allocations() method
"""
bo_allocation_filter = Q(
build__status__in=BuildStatus.ACTIVE_CODES,
)
queryset = queryset.annotate(
allocated_to_build_orders=Coalesce(
SubquerySum('stock_items__allocations__quantity', filter=bo_allocation_filter),
Decimal(0),
output_field=models.DecimalField(),
)
)
# Annotate with the total 'available stock' quantity
# This is the current stock, minus any allocations
queryset = queryset.annotate(
unallocated_stock=ExpressionWrapper(
F('in_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'),
output_field=models.DecimalField(),
)
)
return queryset
def get_starred(self, part):
@ -376,9 +424,12 @@ class PartSerializer(InvenTreeModelSerializer):
category_detail = CategorySerializer(source='category', many=False, read_only=True)
# Calculated fields
allocated_to_build_orders = serializers.FloatField(read_only=True)
allocated_to_sales_orders = serializers.FloatField(read_only=True)
unallocated_stock = serializers.FloatField(read_only=True)
building = serializers.FloatField(read_only=True)
in_stock = serializers.FloatField(read_only=True)
ordering = serializers.FloatField(read_only=True)
building = serializers.FloatField(read_only=True)
stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True)
@ -399,7 +450,8 @@ class PartSerializer(InvenTreeModelSerializer):
partial = True
fields = [
'active',
'allocated_to_build_orders',
'allocated_to_sales_orders',
'assembly',
'category',
'category_detail',
@ -430,6 +482,7 @@ class PartSerializer(InvenTreeModelSerializer):
'suppliers',
'thumbnail',
'trackable',
'unallocated_stock',
'units',
'variant_of',
'virtual',

View File

@ -37,13 +37,17 @@
</button>
{% endif %}
{% if barcodes %}
{% if barcodes or labels_enabled %}
<!-- Barcode actions menu -->
<div class='btn-group'>
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button>
<ul class='dropdown-menu'>
{% if barcodes %}
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
{% endif %}
{% if labels_enabled %}
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
{% endif %}
</ul>
</div>
{% endif %}
@ -424,9 +428,11 @@
);
});
{% if labels_enabled %}
$('#print-label').click(function() {
printPartLabels([{{ part.pk }}]);
});
{% endif %}
function adjustPartStock(action) {
inventreeGet(

View File

@ -9,7 +9,7 @@ from rest_framework import status
from rest_framework.test import APIClient
from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import StockStatus
from InvenTree.status_codes import BuildStatus, StockStatus
from part.models import Part, PartCategory
from part.models import BomItem, BomItemSubstitute
@ -17,6 +17,9 @@ from stock.models import StockItem, StockLocation
from company.models import Company
from common.models import InvenTreeSetting
import build.models
import order.models
class PartOptionsAPITest(InvenTreeAPITestCase):
"""
@ -247,7 +250,7 @@ class PartAPITest(InvenTreeAPITestCase):
data = {'cascade': True}
response = self.client.get(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 13)
self.assertEqual(len(response.data), Part.objects.count())
def test_get_parts_by_cat(self):
url = reverse('api-part-list')
@ -815,6 +818,10 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
'location',
'bom',
'test_templates',
'build',
'location',
'stock',
'sales_order',
]
roles = [
@ -826,6 +833,9 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
super().setUp()
# Ensure the part "variant" tree is correctly structured
Part.objects.rebuild()
# Add a new part
self.part = Part.objects.create(
name='Banana',
@ -880,6 +890,153 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
self.assertEqual(data['in_stock'], 1100)
self.assertEqual(data['stock_item_count'], 105)
def test_allocation_annotations(self):
"""
Tests for query annotations which add allocation information.
Ref: https://github.com/inventree/InvenTree/pull/2797
"""
# We are looking at Part ID 100 ("Bob")
url = reverse('api-part-detail', kwargs={'pk': 100})
part = Part.objects.get(pk=100)
response = self.get(url, expected_code=200)
# Check that the expected annotated fields exist in the data
data = response.data
self.assertEqual(data['allocated_to_build_orders'], 0)
self.assertEqual(data['allocated_to_sales_orders'], 0)
# The unallocated stock count should equal the 'in stock' coutn
in_stock = data['in_stock']
self.assertEqual(in_stock, 126)
self.assertEqual(data['unallocated_stock'], in_stock)
# Check that model functions return the same values
self.assertEqual(part.build_order_allocation_count(), 0)
self.assertEqual(part.sales_order_allocation_count(), 0)
self.assertEqual(part.total_stock, in_stock)
self.assertEqual(part.available_stock, in_stock)
# Now, let's create a sales order, and allocate some stock
so = order.models.SalesOrder.objects.create(
reference='001',
customer=Company.objects.get(pk=1),
)
# We wish to send 50 units of "Bob" against this sales order
line = order.models.SalesOrderLineItem.objects.create(
quantity=50,
order=so,
part=part,
)
# Create a shipment against the order
shipment_1 = order.models.SalesOrderShipment.objects.create(
order=so,
reference='001',
)
shipment_2 = order.models.SalesOrderShipment.objects.create(
order=so,
reference='002',
)
# Allocate stock items to this order, against multiple shipments
order.models.SalesOrderAllocation.objects.create(
line=line,
shipment=shipment_1,
item=StockItem.objects.get(pk=1007),
quantity=17
)
order.models.SalesOrderAllocation.objects.create(
line=line,
shipment=shipment_1,
item=StockItem.objects.get(pk=1008),
quantity=18
)
order.models.SalesOrderAllocation.objects.create(
line=line,
shipment=shipment_2,
item=StockItem.objects.get(pk=1006),
quantity=15,
)
# Submit the API request again - should show us the sales order allocation
data = self.get(url, expected_code=200).data
self.assertEqual(data['allocated_to_sales_orders'], 50)
self.assertEqual(data['in_stock'], 126)
self.assertEqual(data['unallocated_stock'], 76)
# Now, "ship" the first shipment (so the stock is not 'in stock' any more)
shipment_1.complete_shipment(None)
# Refresh the API data
data = self.get(url, expected_code=200).data
self.assertEqual(data['allocated_to_build_orders'], 0)
self.assertEqual(data['allocated_to_sales_orders'], 15)
self.assertEqual(data['in_stock'], 91)
self.assertEqual(data['unallocated_stock'], 76)
# Next, we create a build order and allocate stock against it
bo = build.models.Build.objects.create(
part=Part.objects.get(pk=101),
quantity=10,
title='Making some assemblies',
status=BuildStatus.PRODUCTION,
)
bom_item = BomItem.objects.get(pk=6)
# Allocate multiple stock items against this build order
build.models.BuildItem.objects.create(
build=bo,
bom_item=bom_item,
stock_item=StockItem.objects.get(pk=1000),
quantity=10,
)
# Request data once more
data = self.get(url, expected_code=200).data
self.assertEqual(data['allocated_to_build_orders'], 10)
self.assertEqual(data['allocated_to_sales_orders'], 15)
self.assertEqual(data['in_stock'], 91)
self.assertEqual(data['unallocated_stock'], 66)
# Again, check that the direct model functions return the same values
self.assertEqual(part.build_order_allocation_count(), 10)
self.assertEqual(part.sales_order_allocation_count(), 15)
self.assertEqual(part.total_stock, 91)
self.assertEqual(part.available_stock, 66)
# Allocate further stock against the build
build.models.BuildItem.objects.create(
build=bo,
bom_item=bom_item,
stock_item=StockItem.objects.get(pk=1001),
quantity=10,
)
# Request data once more
data = self.get(url, expected_code=200).data
self.assertEqual(data['allocated_to_build_orders'], 20)
self.assertEqual(data['allocated_to_sales_orders'], 15)
self.assertEqual(data['in_stock'], 91)
self.assertEqual(data['unallocated_stock'], 56)
# Again, check that the direct model functions return the same values
self.assertEqual(part.build_order_allocation_count(), 20)
self.assertEqual(part.sales_order_allocation_count(), 15)
self.assertEqual(part.total_stock, 91)
self.assertEqual(part.available_stock, 56)
class BomItemTest(InvenTreeAPITestCase):
"""

View File

@ -46,7 +46,7 @@ class BomItemTest(TestCase):
# TODO: Tests for multi-level BOMs
def test_used_in(self):
self.assertEqual(self.bob.used_in_count, 0)
self.assertEqual(self.bob.used_in_count, 1)
self.assertEqual(self.orphan.used_in_count, 1)
def test_self_reference(self):

View File

@ -251,3 +251,104 @@
rght: 0
expiry_date: "1990-10-10"
status: 70
# Multiple stock items for "Bob" (PK 100)
- model: stock.stockitem
pk: 1000
fields:
part: 100
location: 1
quantity: 10
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 1001
fields:
part: 100
location: 1
quantity: 11
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 1002
fields:
part: 100
location: 1
quantity: 12
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 1003
fields:
part: 100
location: 1
quantity: 13
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 1004
fields:
part: 100
location: 1
quantity: 14
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 1005
fields:
part: 100
location: 1
quantity: 15
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 1006
fields:
part: 100
location: 1
quantity: 16
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 1007
fields:
part: 100
location: 7
quantity: 17
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem
pk: 1008
fields:
part: 100
location: 7
quantity: 18
level: 0
tree_id: 0
lft: 0
rght: 0

View File

@ -49,15 +49,20 @@
</div>
{% endif %}
<!-- Document / label menu -->
{% if test_report_enabled or labels_enabled %}
<div class='btn-group' role='group'>
<button id='document-options' title='{% trans "Printing actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'><span class='fas fa-print'></span> <span class='caret'></span></button>
<ul class='dropdown-menu' role='menu'>
{% if labels_enabled %}
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
{% endif %}
{% if test_report_enabled %}
<li><a class='dropdown-item' href='#' id='stock-test-report'><span class='fas fa-file-pdf'></span> {% trans "Test Report" %}</a></li>
{% endif %}
</ul>
</div>
{% endif %}
<!-- Stock adjustment menu -->
{% if user_owns_item %}
{% if roles.stock.change and not item.is_building %}

View File

@ -34,7 +34,9 @@
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button>
<ul class='dropdown-menu'>
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
{% if labels_enabled %}
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href='#' id='barcode-check-in'><span class='fas fa-arrow-right'></span> {% trans "Check-in Items" %}</a></li>
</ul>
</div>
@ -181,6 +183,7 @@
<div id='sublocation-button-toolbar'>
<div class='btn-group' role='group'>
<!-- Printing actions menu -->
{% if labels_enabled %}
<div class='btn-group' role='group'>
<button id='location-print-options' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle="dropdown" title='{% trans "Printing Actions" %}'>
<span class='fas fa-print'></span> <span class='caret'></span>
@ -189,6 +192,7 @@
<li><a class='dropdown-item' href='#' id='multi-location-print-label' title='{% trans "Print labels" %}'><span class='fas fa-tags'></span> {% trans "Print labels" %}</a></li>
</ul>
</div>
{% endif %}
{% include "filter_list.html" with id="location" %}
</div>
</div>
@ -222,6 +226,15 @@
]
);
{% if labels_enabled %}
$('#print-label').click(function() {
var locs = [{{ location.pk }}];
printStockLocationLabels(locs);
});
$('#multi-location-print-label').click(function() {
var selections = $('#sublocation-table').bootstrapTable('getSelections');
@ -234,6 +247,7 @@
printStockLocationLabels(locations);
});
{% endif %}
{% if location %}
$("#barcode-check-in").click(function() {
@ -298,14 +312,6 @@
adjustLocationStock('move');
});
$('#print-label').click(function() {
var locs = [{{ location.pk }}];
printStockLocationLabels(locs);
});
{% endif %}
$('#show-qr-code').click(function() {

View File

@ -104,7 +104,7 @@ class StockItemListTest(StockAPITestCase):
response = self.get_stock()
self.assertEqual(len(response), 20)
self.assertEqual(len(response), 29)
def test_filter_by_part(self):
"""
@ -113,7 +113,7 @@ class StockItemListTest(StockAPITestCase):
response = self.get_stock(part=25)
self.assertEqual(len(response), 8)
self.assertEqual(len(response), 17)
response = self.get_stock(part=10004)
@ -136,13 +136,13 @@ class StockItemListTest(StockAPITestCase):
self.assertEqual(len(response), 1)
response = self.get_stock(location=1, cascade=0)
self.assertEqual(len(response), 0)
self.assertEqual(len(response), 7)
response = self.get_stock(location=1, cascade=1)
self.assertEqual(len(response), 2)
self.assertEqual(len(response), 9)
response = self.get_stock(location=7)
self.assertEqual(len(response), 16)
self.assertEqual(len(response), 18)
def test_filter_by_depleted(self):
"""
@ -153,7 +153,7 @@ class StockItemListTest(StockAPITestCase):
self.assertEqual(len(response), 1)
response = self.get_stock(depleted=0)
self.assertEqual(len(response), 19)
self.assertEqual(len(response), 28)
def test_filter_by_in_stock(self):
"""
@ -161,7 +161,7 @@ class StockItemListTest(StockAPITestCase):
"""
response = self.get_stock(in_stock=1)
self.assertEqual(len(response), 17)
self.assertEqual(len(response), 26)
response = self.get_stock(in_stock=0)
self.assertEqual(len(response), 3)
@ -172,7 +172,7 @@ class StockItemListTest(StockAPITestCase):
"""
codes = {
StockStatus.OK: 18,
StockStatus.OK: 27,
StockStatus.DESTROYED: 1,
StockStatus.LOST: 1,
StockStatus.DAMAGED: 0,
@ -205,7 +205,7 @@ class StockItemListTest(StockAPITestCase):
self.assertIsNotNone(item['serial'])
response = self.get_stock(serialized=0)
self.assertEqual(len(response), 8)
self.assertEqual(len(response), 17)
for item in response:
self.assertIsNone(item['serial'])
@ -217,7 +217,7 @@ class StockItemListTest(StockAPITestCase):
# First, we can assume that the 'stock expiry' feature is disabled
response = self.get_stock(expired=1)
self.assertEqual(len(response), 20)
self.assertEqual(len(response), 29)
self.user.is_staff = True
self.user.save()
@ -232,7 +232,7 @@ class StockItemListTest(StockAPITestCase):
self.assertTrue(item['expired'])
response = self.get_stock(expired=0)
self.assertEqual(len(response), 19)
self.assertEqual(len(response), 28)
for item in response:
self.assertFalse(item['expired'])
@ -249,7 +249,7 @@ class StockItemListTest(StockAPITestCase):
self.assertEqual(len(response), 4)
response = self.get_stock(expired=0)
self.assertEqual(len(response), 16)
self.assertEqual(len(response), 25)
def test_paginate(self):
"""
@ -290,7 +290,8 @@ class StockItemListTest(StockAPITestCase):
dataset = self.export_data({})
self.assertEqual(len(dataset), 20)
# Check that *all* stock item objects have been exported
self.assertEqual(len(dataset), StockItem.objects.count())
# Expected headers
headers = [
@ -308,11 +309,11 @@ class StockItemListTest(StockAPITestCase):
# Now, add a filter to the results
dataset = self.export_data({'location': 1})
self.assertEqual(len(dataset), 2)
self.assertEqual(len(dataset), 9)
dataset = self.export_data({'part': 25})
self.assertEqual(len(dataset), 8)
self.assertEqual(len(dataset), 17)
class StockItemTest(StockAPITestCase):

View File

@ -167,8 +167,8 @@ class StockTest(TestCase):
self.assertFalse(self.drawer2.has_items())
# Drawer 3 should have three stock items
self.assertEqual(self.drawer3.stock_items.count(), 16)
self.assertEqual(self.drawer3.item_count, 16)
self.assertEqual(self.drawer3.stock_items.count(), 18)
self.assertEqual(self.drawer3.item_count, 18)
def test_stock_count(self):
part = Part.objects.get(pk=1)

View File

@ -14,6 +14,7 @@
<div class='row'>
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="LABEL_ENABLE" icon='fa-toggle-on' user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="LABEL_INLINE" icon='fa-tag' user_setting=True %}
</tbody>
</table>

View File

@ -14,8 +14,16 @@
<div class='row'>
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_PARTS" user_setting=True icon='fa-shapes' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_CATEGORIES" user_setting=True icon='fa-sitemap' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_STOCK" user_setting=True icon='fa-boxes' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_LOCATIONS" user_setting=True icon='fa-sitemap' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_COMPANIES" user_setting=True icon='fa-building' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS" user_setting=True icon='fa-shopping-cart' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_SHOW_SALES_ORDERS" user_setting=True icon='fa-truck' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_RESULTS" user_setting=True icon='fa-search' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_SHOW_STOCK_LEVELS" user_setting=True icon='fa-boxes' %}
{% include "InvenTree/settings/setting.html" with key="SEARCH_HIDE_INACTIVE_PARTS" user_setting=True icon='fa-eye-slash' %}
</tbody>
</table>

View File

@ -6,6 +6,7 @@
{% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %}
{% settings_value "REPORT_ENABLE" as report_enabled %}
{% settings_value "SERVER_RESTART_REQUIRED" as server_restart_required %}
{% settings_value "LABEL_ENABLE" with user=user as labels_enabled %}
{% inventree_demo_mode as demo_mode %}
<!DOCTYPE html>
@ -126,9 +127,11 @@
{% endblock %}
</main>
</div>
{% include 'modals.html' %}
{% include 'about.html' %}
{% include "notifications.html" %}
{% include "search.html" %}
</div>
<!-- Scripts -->
@ -185,6 +188,7 @@
<script type='text/javascript' src="{% i18n_static 'order.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'part.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'report.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'search.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'stock.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'plugin.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'tables.js' %}"></script>

View File

@ -4,6 +4,7 @@
editSetting,
user_settings,
global_settings,
plugins_enabled,
*/
{% user_settings request.user as USER_SETTINGS %}
@ -20,6 +21,13 @@ const global_settings = {
{% endfor %}
};
{% plugins_enabled as p_en %}
{% if p_en %}
const plugins_enabled = true;
{% else %}
const plugins_enabled = false;
{% endif %}
/*
* Edit a setting value
*/

View File

@ -1746,7 +1746,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
required: true,
render_part_detail: true,
render_location_detail: true,
render_stock_id: false,
render_pk: false,
auto_fill: true,
auto_fill_filters: auto_fill_filters,
onSelect: function(data, field, opts) {

View File

@ -10,6 +10,7 @@
modalSetTitle,
modalSubmit,
openModal,
plugins_enabled,
showAlertDialog,
*/
@ -232,26 +233,28 @@ function selectLabel(labels, items, options={}) {
var plugins = [];
// Request a list of available label printing plugins from the server
inventreeGet(
`/api/plugin/`,
{},
{
async: false,
success: function(response) {
response.forEach(function(plugin) {
// Look for active plugins which implement the 'labels' mixin class
if (plugin.active && plugin.mixins && plugin.mixins.labels) {
// This plugin supports label printing
plugins.push(plugin);
}
});
if (plugins_enabled) {
inventreeGet(
`/api/plugin/`,
{},
{
async: false,
success: function(response) {
response.forEach(function(plugin) {
// Look for active plugins which implement the 'labels' mixin class
if (plugin.active && plugin.mixins && plugin.mixins.labels) {
// This plugin supports label printing
plugins.push(plugin);
}
});
}
}
}
);
);
}
var plugin_selection = '';
if (plugins.length > 0) {
if (plugins_enabled && plugins.length > 0) {
plugin_selection =`
<div class='form-group'>
<label class='control-label requiredField' for='id_plugin'>

View File

@ -10,7 +10,9 @@
renderCompany,
renderManufacturerPart,
renderOwner,
renderPart,
renderPartCategory,
renderStockItem,
renderStockLocation,
renderSupplierPart,
*/
@ -29,15 +31,33 @@
*/
// Should the ID be rendered for this string
function renderId(title, pk, parameters={}) {
// Default = true
var render = true;
if ('render_pk' in parameters) {
render = parameters['render_pk'];
}
if (render) {
return `<span class='float-right'><small>${title}: ${pk}</small></span>`;
} else {
return '';
}
}
// Renderer for "Company" model
// eslint-disable-next-line no-unused-vars
function renderCompany(name, data, parameters, options) {
function renderCompany(name, data, parameters={}, options={}) {
var html = select2Thumbnail(data.image);
html += `<span><b>${data.name}</b></span> - <i>${data.description}</i>`;
html += `<span class='float-right'><small>{% trans "Company ID" %}: ${data.pk}</small></span>`;
html += renderId('{% trans "Company ID" %}', data.pk, parameters);
return html;
}
@ -45,7 +65,7 @@ function renderCompany(name, data, parameters, options) {
// Renderer for "StockItem" model
// eslint-disable-next-line no-unused-vars
function renderStockItem(name, data, parameters, options) {
function renderStockItem(name, data, parameters={}, options={}) {
var image = blankImage();
@ -65,18 +85,6 @@ function renderStockItem(name, data, parameters, options) {
part_detail = `<img src='${image}' class='select2-thumbnail'><span>${data.part_detail.full_name}</span> - `;
}
var render_stock_id = true;
if ('render_stock_id' in parameters) {
render_stock_id = parameters['render_stock_id'];
}
var stock_id = '';
if (render_stock_id) {
stock_id = `<span class='float-right'><small>{% trans "Stock ID" %}: ${data.pk}</small></span>`;
}
var render_location_detail = false;
if ('render_location_detail' in parameters) {
@ -86,7 +94,7 @@ function renderStockItem(name, data, parameters, options) {
var location_detail = '';
if (render_location_detail && data.location_detail) {
location_detail = ` - (<em>${data.location_detail.name}</em>)`;
location_detail = ` <small>- (<em>${data.location_detail.name}</em>)</small>`;
}
var stock_detail = '';
@ -101,7 +109,10 @@ function renderStockItem(name, data, parameters, options) {
var html = `
<span>
${part_detail}${stock_detail}${location_detail}${stock_id}
${part_detail}
${stock_detail}
${location_detail}
${renderId('{% trans "Stock ID" %}', data.pk, parameters)}
</span>
`;
@ -111,7 +122,7 @@ function renderStockItem(name, data, parameters, options) {
// Renderer for "StockLocation" model
// eslint-disable-next-line no-unused-vars
function renderStockLocation(name, data, parameters, options) {
function renderStockLocation(name, data, parameters={}, options={}) {
var level = '- '.repeat(data.level);
@ -133,7 +144,7 @@ function renderStockLocation(name, data, parameters, options) {
}
// eslint-disable-next-line no-unused-vars
function renderBuild(name, data, parameters, options) {
function renderBuild(name, data, parameters={}, options={}) {
var image = null;
@ -154,7 +165,7 @@ function renderBuild(name, data, parameters, options) {
// Renderer for "Part" model
// eslint-disable-next-line no-unused-vars
function renderPart(name, data, parameters, options) {
function renderPart(name, data, parameters={}, options={}) {
var html = select2Thumbnail(data.image);
@ -164,13 +175,14 @@ function renderPart(name, data, parameters, options) {
html += ` - <i><small>${data.description}</small></i>`;
}
var extra = '';
var stock_data = '';
// Display available part quantity
if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) {
extra += partStockLabel(data);
stock_data = partStockLabel(data);
}
var extra = '';
if (!data.active) {
extra += `<span class='badge badge-right rounded-pill bg-danger'>{% trans "Inactive" %}</span>`;
}
@ -178,8 +190,9 @@ function renderPart(name, data, parameters, options) {
html += `
<span class='float-right'>
<small>
${stock_data}
${extra}
{% trans "Part ID" %}: ${data.pk}
${renderId('{% trans "Part ID" $}', data.pk, parameters)}
</small>
</span>`;
@ -188,7 +201,7 @@ function renderPart(name, data, parameters, options) {
// Renderer for "User" model
// eslint-disable-next-line no-unused-vars
function renderUser(name, data, parameters, options) {
function renderUser(name, data, parameters={}, options={}) {
var html = `<span>${data.username}</span>`;
@ -202,7 +215,7 @@ function renderUser(name, data, parameters, options) {
// Renderer for "Owner" model
// eslint-disable-next-line no-unused-vars
function renderOwner(name, data, parameters, options) {
function renderOwner(name, data, parameters={}, options={}) {
var html = `<span>${data.name}</span>`;
@ -223,15 +236,13 @@ function renderOwner(name, data, parameters, options) {
// Renderer for "PurchaseOrder" model
// eslint-disable-next-line no-unused-vars
function renderPurchaseOrder(name, data, parameters, options) {
var html = '';
function renderPurchaseOrder(name, data, parameters={}, options={}) {
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX;
var html = `<span>${prefix}${data.reference}</span>`;
var thumbnail = null;
html += `<span>${prefix}${data.reference}</span>`;
if (data.supplier_detail) {
thumbnail = data.supplier_detail.thumbnail || data.supplier_detail.image;
@ -243,13 +254,7 @@ function renderPurchaseOrder(name, data, parameters, options) {
html += ` - <em>${data.description}</em>`;
}
html += `
<span class='float-right'>
<small>
{% trans "Order ID" %}: ${data.pk}
</small>
</span>
`;
html += renderId('{% trans "Order ID" %}', data.pk, parameters);
return html;
}
@ -257,19 +262,25 @@ function renderPurchaseOrder(name, data, parameters, options) {
// Renderer for "SalesOrder" model
// eslint-disable-next-line no-unused-vars
function renderSalesOrder(name, data, parameters, options) {
var html = `<span>${data.reference}</span>`;
function renderSalesOrder(name, data, parameters={}, options={}) {
var prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
var html = `<span>${prefix}${data.reference}</span>`;
var thumbnail = null;
if (data.customer_detail) {
thumbnail = data.customer_detail.thumbnail || data.customer_detail.image;
html += ' - ' + select2Thumbnail(thumbnail);
html += `<span>${data.customer_detail.name}</span>`;
}
if (data.description) {
html += ` - <em>${data.description}</em>`;
}
html += `
<span class='float-right'>
<small>
{% trans "Order ID" %}: ${data.pk}
</small>
</span>`;
html += renderId('{% trans "Order ID" %}', data.pk, parameters);
return html;
}
@ -277,7 +288,7 @@ function renderSalesOrder(name, data, parameters, options) {
// Renderer for "SalesOrderShipment" model
// eslint-disable-next-line no-unused-vars
function renderSalesOrderShipment(name, data, parameters, options) {
function renderSalesOrderShipment(name, data, parameters={}, options={}) {
var so_prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
@ -294,7 +305,7 @@ function renderSalesOrderShipment(name, data, parameters, options) {
// Renderer for "PartCategory" model
// eslint-disable-next-line no-unused-vars
function renderPartCategory(name, data, parameters, options) {
function renderPartCategory(name, data, parameters={}, options={}) {
var level = '- '.repeat(data.level);
@ -310,7 +321,7 @@ function renderPartCategory(name, data, parameters, options) {
}
// eslint-disable-next-line no-unused-vars
function renderPartParameterTemplate(name, data, parameters, options) {
function renderPartParameterTemplate(name, data, parameters={}, options={}) {
var units = '';
@ -326,7 +337,7 @@ function renderPartParameterTemplate(name, data, parameters, options) {
// Renderer for "ManufacturerPart" model
// eslint-disable-next-line no-unused-vars
function renderManufacturerPart(name, data, parameters, options) {
function renderManufacturerPart(name, data, parameters={}, options={}) {
var manufacturer_image = null;
var part_image = null;
@ -355,7 +366,7 @@ function renderManufacturerPart(name, data, parameters, options) {
// Renderer for "SupplierPart" model
// eslint-disable-next-line no-unused-vars
function renderSupplierPart(name, data, parameters, options) {
function renderSupplierPart(name, data, parameters={}, options={}) {
var supplier_image = null;
var part_image = null;

View File

@ -491,13 +491,50 @@ function duplicateBom(part_id, options={}) {
}
/*
* Construct a "badge" label showing stock information for this particular part
*/
function partStockLabel(part, options={}) {
if (part.in_stock) {
return `<span class='badge rounded-pill bg-success ${options.classes}'>{% trans "Stock" %}: ${part.in_stock}</span>`;
// There IS stock available for this part
// Is stock "low" (below the 'minimum_stock' quantity)?
if ((part.minimum_stock > 0) && (part.minimum_stock > part.in_stock)) {
return `<span class='badge rounded-pill bg-warning ${options.classes}'>{% trans "Low stock" %}: ${part.in_stock}${part.units}</span>`;
} else if (part.unallocated_stock == 0) {
if (part.ordering) {
// There is no available stock, but stock is on order
return `<span class='badge rounded-pill bg-info ${options.classes}'>{% trans "On Order" %}: ${part.ordering}${part.units}</span>`;
} else if (part.building) {
// There is no available stock, but stock is being built
return `<span class='badge rounded-pill bg-info ${options.classes}'>{% trans "Building" %}: ${part.building}${part.units}</span>`;
} else {
// There is no available stock at all
return `<span class='badge rounded-pill bg-warning ${options.classes}'>{% trans "No stock available" %}</span>`;
}
} else if (part.unallocated_stock < part.in_stock) {
// Unallocated quanttiy is less than total quantity
return `<span class='badge rounded-pill bg-success ${options.classes}'>{% trans "Available" %}: ${part.unallocated_stock}/${part.in_stock}${part.units}</span>`;
} else {
// Stock is completely available
return `<span class='badge rounded-pill bg-success ${options.classes}'>{% trans "Available" %}: ${part.unallocated_stock}${part.units}</span>`;
}
} else {
return `<span class='badge rounded-pill bg-danger ${options.classes}'>{% trans "No Stock" %}</span>`;
// There IS NO stock available for this part
if (part.ordering) {
// There is no stock, but stock is on order
return `<span class='badge rounded-pill bg-info ${options.classes}'>{% trans "On Order" %}: ${part.ordering}${part.units}</span>`;
} else if (part.building) {
// There is no stock, but stock is being built
return `<span class='badge rounded-pill bg-info ${options.classes}'>{% trans "Building" %}: ${part.building}${part.units}</span>`;
} else {
// There is no stock
return `<span class='badge rounded-pill bg-danger ${options.classes}'>{% trans "No Stock" %}</span>`;
}
}
}
@ -1160,12 +1197,14 @@ function partGridTile(part) {
if (!part.in_stock) {
stock = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock" %}</span>`;
} else if (!part.unallocated_stock) {
stock = `<span class='badge rounded-pill bg-warning'>{% trans "Not available" %}</span>`;
}
rows += `<tr><td><b>{% trans "Stock" %}</b></td><td>${stock}</td></tr>`;
if (part.on_order) {
rows += `<tr><td><b>{$ trans "On Order" %}</b></td><td>${part.on_order}</td></tr>`;
if (part.ordering) {
rows += `<tr><td><b>{% trans "On Order" %}</b></td><td>${part.ordering}</td></tr>`;
}
if (part.building) {
@ -1322,31 +1361,47 @@ function loadPartTable(table, url, options={}) {
columns.push(col);
col = {
field: 'in_stock',
field: 'unallocated_stock',
title: '{% trans "Stock" %}',
searchable: false,
formatter: function(value, row) {
var link = '?display=part-stock';
if (value) {
if (row.in_stock) {
// There IS stock available for this part
// Is stock "low" (below the 'minimum_stock' quantity)?
if (row.minimum_stock && row.minimum_stock > value) {
if (row.minimum_stock && row.minimum_stock > row.in_stock) {
value += `<span class='badge badge-right rounded-pill bg-warning'>{% trans "Low stock" %}</span>`;
} else if (value == 0) {
if (row.ordering) {
// There is no available stock, but stock is on order
value = `0<span class='badge badge-right rounded-pill bg-info'>{% trans "On Order" %}: ${row.ordering}</span>`;
link = '?display=purchase-orders';
} else if (row.building) {
// There is no available stock, but stock is being built
value = `0<span class='badge badge-right rounded-pill bg-info'>{% trans "Building" %}: ${row.building}</span>`;
link = '?display=build-orders';
} else {
// There is no available stock
value = `0<span class='badge badge-right rounded-pill bg-warning'>{% trans "No stock available" %}</span>`;
}
}
} else if (row.on_order) {
// There is no stock available, but stock is on order
value = `0<span class='badge badge-right rounded-pill bg-info'>{% trans "On Order" %}: ${row.on_order}</span>`;
link = '?display=purchase-orders';
} else if (row.building) {
// There is no stock available, but stock is being built
value = `0<span class='badge badge-right rounded-pill bg-info'>{% trans "Building" %}: ${row.building}</span>`;
link = '?display=build-orders';
} else {
// There is no stock available
value = `0<span class='badge badge-right rounded-pill bg-danger'>{% trans "No Stock" %}</span>`;
// There IS NO stock available for this part
if (row.ordering) {
// There is no stock, but stock is on order
value = `0<span class='badge badge-right rounded-pill bg-info'>{% trans "On Order" %}: ${row.ordering}</span>`;
link = '?display=purchase-orders';
} else if (row.building) {
// There is no stock, but stock is being built
value = `0<span class='badge badge-right rounded-pill bg-info'>{% trans "Building" %}: ${row.building}</span>`;
link = '?display=build-orders';
} else {
// There is no stock
value = `0<span class='badge badge-right rounded-pill bg-danger'>{% trans "No Stock" %}</span>`;
}
}
return renderLink(value, `/part/${row.pk}/${link}`);

View File

@ -0,0 +1,329 @@
{% load i18n %}
/* globals
*/
/* exported
closeSearchPanel,
openSearchPanel,
searchTextChanged,
*/
/*
* Callback when the search panel is closed
*/
function closeSearchPanel() {
}
/*
* Callback when the search panel is opened.
* Ensure the panel is in a known state
*/
function openSearchPanel() {
var panel = $('#offcanvas-search');
clearSearchResults();
panel.find('#search-input').on('keyup change', searchTextChanged);
// Callback for "clear search" button
panel.find('#search-clear').click(function(event) {
// Prevent this button from actually submitting the form
event.preventDefault();
panel.find('#search-input').val('');
clearSearchResults();
});
// Callback for the "close search" button
panel.find('#search-close').click(function(event) {
// Prevent this button from actually submitting the form
event.preventDefault();
});
}
var searchInputTimer = null;
var searchText = null;
var searchTextCurrent = null;
var searchQueries = [];
function searchTextChanged(event) {
searchText = $('#offcanvas-search').find('#search-input').val();
clearTimeout(searchInputTimer);
searchInputTimer = setTimeout(updateSearch, 250);
};
function updateSearch() {
if (searchText == searchTextCurrent) {
return;
}
clearSearchResults();
if (searchText.length == 0) {
return;
}
searchTextCurrent = searchText;
// Cancel any previous AJAX requests
searchQueries.forEach(function(query) {
query.abort();
});
searchQueries = [];
// Show the "searching" text
$('#offcanvas-search').find('#search-pending').show();
if (user_settings.SEARCH_PREVIEW_SHOW_PARTS) {
var params = {};
if (user_settings.SEARCH_HIDE_INACTIVE_PARTS) {
params.active = false;
}
// Search for matching parts
addSearchQuery(
'part',
'{% trans "Parts" %}',
'{% url "api-part-list" %}',
params,
renderPart,
{
url: '/part',
}
);
}
if (user_settings.SEARCH_PREVIEW_SHOW_CATEGORIES) {
// Search for matching part categories
addSearchQuery(
'category',
'{% trans "Part Categories" %}',
'{% url "api-part-category-list" %}',
{},
renderPartCategory,
{
url: '/part/category',
},
);
}
if (user_settings.SEARCH_PREVIEW_SHOW_STOCK) {
// Search for matching stock items
addSearchQuery(
'stock',
'{% trans "Stock Items" %}',
'{% url "api-stock-list" %}',
{
part_detail: true,
location_detail: true,
},
renderStockItem,
{
url: '/stock/item',
render_location_detail: true,
}
);
}
if (user_settings.SEARCH_PREVIEW_SHOW_LOCATIONS) {
// Search for matching stock locations
addSearchQuery(
'location',
'{% trans "Stock Locations" %}',
'{% url "api-location-list" %}',
{},
renderStockLocation,
{
url: '/stock/location',
}
);
}
if (user_settings.SEARCH_PREVIEW_SHOW_COMPANIES) {
// Search for matching companies
addSearchQuery(
'company',
'{% trans "Companies" %}',
'{% url "api-company-list" %}',
{},
renderCompany,
{
url: '/company',
}
);
}
if (user_settings.SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS) {
// Search for matching purchase orders
addSearchQuery(
'purchaseorder',
'{% trans "Purchase Orders" %}',
'{% url "api-po-list" %}',
{
supplier_detail: true,
outstanding: true,
},
renderPurchaseOrder,
{
url: '/order/purchase-order',
}
);
}
if (user_settings.SEARCH_PREVIEW_SHOW_SALES_ORDERS) {
// Search for matching sales orders
addSearchQuery(
'salesorder',
'{% trans "Sales Orders" %}',
'{% url "api-so-list" %}',
{
customer_detail: true,
outstanding: true,
},
renderSalesOrder,
{
url: '/order/sales-order',
}
);
}
// Wait until all the pending queries are completed
$.when.apply($, searchQueries).done(function() {
$('#offcanvas-search').find('#search-pending').hide();
});
}
function clearSearchResults() {
var panel = $('#offcanvas-search');
// Ensure the 'no results found' element is visible
panel.find('#search-no-results').show();
// Ensure that the 'searching' element is hidden
panel.find('#search-pending').hide();
// Delete any existing search results
panel.find('#search-results').empty();
// Finally, grab keyboard focus in the search bar
panel.find('#search-input').focus();
}
function addSearchQuery(key, title, query_url, query_params, render_func, render_params={}) {
// Include current search term
query_params.search = searchTextCurrent;
// How many results to show in each group?
query_params.offset = 0;
query_params.limit = user_settings.SEARCH_PREVIEW_RESULTS;
// Do not display "pk" value for search results
render_params.render_pk = false;
// Add the result group to the panel
$('#offcanvas-search').find('#search-results').append(`
<div class='search-result-group-wrapper' id='search-results-wrapper-${key}'></div>
`);
var request = inventreeGet(
query_url,
query_params,
{
success: function(response) {
addSearchResults(
key,
response.results,
title,
render_func,
render_params,
);
}
},
);
// Add the query to the stack
searchQueries.push(request);
}
// Add a group of results to the list
function addSearchResults(key, results, title, renderFunc, renderParams={}) {
if (results.length == 0) {
// Do not display this group, as there are no results
return;
}
var panel = $('#offcanvas-search');
// Ensure the 'no results found' element is hidden
panel.find('#search-no-results').hide();
panel.find(`#search-results-wrapper-${key}`).append(`
<div class='search-result-group' id='search-results-${key}'>
<div class='search-result-header' style='display: flex;'>
<h5>${title}</h5>
<span class='flex' style='flex-grow: 1;'></span>
<div class='search-result-group-buttons btn-group float-right' role='group'>
<button class='btn btn-outline-secondary' id='hide-results-${key}' title='{% trans "Minimize results" %}'>
<span class='fas fa-chevron-up'></span>
</button>
<button class='btn btn-outline-secondary' id='remove-results-${key}' title='{% trans "Remove results" %}'>
<span class='fas fa-times icon-red'></span>
</button>
</div>
</div>
<div class='collapse search-result-list' id='search-result-list-${key}'>
</div>
</div>
`);
results.forEach(function(result) {
var pk = result.pk || result.id;
var html = renderFunc(key, result, renderParams);
if (renderParams.url) {
html = `<a href='${renderParams.url}/${pk}/'>` + html + `</a>`;
}
var result_html = `
<div class='search-result-entry' id='search-result-${key}-${pk}'>
${html}
</div>
`;
panel.find(`#search-result-list-${key}`).append(result_html);
});
// Expand results panel
panel.find(`#search-result-list-${key}`).toggle();
// Add callback for "toggle" button
panel.find(`#hide-results-${key}`).click(function() {
panel.find(`#search-result-list-${key}`).toggle();
});
// Add callback for "remove" button
panel.find(`#remove-results-${key}`).click(function() {
panel.find(`#search-results-${key}`).remove();
});
}

View File

@ -427,12 +427,16 @@ function getAvailableTableFilters(tableKey) {
},
has_stock: {
type: 'bool',
title: '{% trans "Stock available" %}',
title: '{% trans "In stock" %}',
},
low_stock: {
type: 'bool',
title: '{% trans "Low stock" %}',
},
unallocated_stock: {
type: 'bool',
title: '{% trans "Available stock" %}',
},
assembly: {
type: 'bool',
title: '{% trans "Assembly" %}',

View File

@ -87,18 +87,25 @@
{% if demo %}
{% include "navbar_demo.html" %}
{% endif %}
{% include "search_form.html" %}
<ul class='navbar-nav flex-row'>
<li class='nav-item me-2'>
<button data-bs-toggle='offcanvas' data-bs-target="#offcanvas-search" class='btn position-relative' title='{% trans "Search" %}'>
<span class='fas fa-search'></span>
</button>
</li>
{% if barcodes %}
<li class='nav-item' id='navbar-barcode-li'>
<button id='barcode-scan' class='btn btn-secondary' title='{% trans "Scan Barcode" %}'>
<button id='barcode-scan' class='btn position-relative' title='{% trans "Scan Barcode" %}'>
<span class='fas fa-qrcode'></span>
</button>
</li>
{% endif %}
<li class='nav-item me-2'>
<button data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" class='btn position-relative' title='{% trans "Show Notifications" %}'>
<button data-bs-toggle="offcanvas" data-bs-target="#offcanvas-notification" class='btn position-relative' title='{% trans "Show Notifications" %}'>
<span class='fas fa-bell'></span>
<span class="position-absolute top-100 start-100 translate-middle badge rounded-pill bg-danger d-none" id="notification-alert">
<span class="visually-hidden">{% trans "New Notifications" %}</span>

View File

@ -1,7 +1,8 @@
{% load i18n %}
<div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvasRight" data-bs-scroll="true" aria-labelledby="offcanvasRightLabel">
<div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvas-notification" data-bs-scroll="true" aria-labelledby="offcanvas-notification-label">
<div class="offcanvas-header">
<h5 id="offcanvasRightLabel">{% trans "Notifications" %}</h5>
<h5 id="offcanvas-notification-label">{% trans "Notifications" %}</h5>
<button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
@ -11,4 +12,5 @@
<hr>
<a href="{% url 'notifications' %}">{% trans "Show all notifications and history" %}</a>
</div>
</div>
</div>

View File

@ -0,0 +1,43 @@
{% load i18n %}
<div class="offcanvas offcanvas-end search-result-panel" tabindex="-1" id="offcanvas-search" data-bs-scroll="true" aria-labelledby="offcanvas-search-label">
<div class="offcanvas-header">
<form action='{% url "search" %}' method='post' class='d-flex' style='width: 100%;'>
{% csrf_token %}
<div class='input-group'>
<input type="text" name='search' class="form-control" aria-label='{% trans "Search" %}' id="search-input" placeholder="{% trans 'Search' %}" autofocus>
<button type='submit' id='search-complete' class='btn btn-outline-secondary' title='{% trans "Show full search results" %}'>
<span class='fas fa-search'></span>
</button>
<button id='search-clear' class='btn btn-outline-secondary' title='{% trans "Clear search" %}'>
<span class='fas fa-backspace'></span>
</button>
<!--
<button id='search-filter' class="btn btn-outline-secondary" title='{% trans "Filter results" %}'>
<span class='fas fa-filter'></span>
</button>
-->
<button id='search-close' class="btn btn-outline-secondary" data-bs-dismiss='offcanvas' title='{% trans "Close search menu" %}'>
<span class='fas fa-times icon-red'></span>
</button>
</div>
</form>
</div>
<div class="offcanvas-body">
<div id="search-center">
<p id='search-pending' class='text-muted' display='none'>
<em>{% trans "Searching" %}...</em>
<span class='float-right'>
<span class='fas fa-spinner fa-spin'></span>
</span>
</p>
<p id='search-no-results' class='text-muted'>
<em>{% trans "No search results" %}</em>
</p>
<div id='search-results'>
<!-- Search results go here -->
</div>
</div>
</div>
</div>

View File

@ -1,7 +1,7 @@
# Base python requirements for docker containers
# Basic package requirements
setuptools>=57.4.0,<=60.1.0
setuptools==60.0.5
wheel>=0.37.0
invoke>=1.4.0 # Invoke build tool