Merge branch 'master' into feat-qr-scanner

This commit is contained in:
Kalman Rozsahegyi 2022-04-28 22:09:01 +02:00
commit 9e1a143132
142 changed files with 72385 additions and 37312 deletions

View File

@ -0,0 +1,159 @@
"""
InvenTree API version information
"""
# InvenTree API version
INVENTREE_API_VERSION = 43
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v43 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2875
- Adds API detail endpoint for PartSalePrice model
- Adds API detail endpoint for PartInternalPrice model
v42 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2833
- Adds variant stock information to the Part and BomItem serializers
v41 -> 2022-04-26
- Fixes 'variant_of' filter for Part list endpoint
v40 -> 2022-04-19
- Adds ability to filter StockItem list by "tracked" parameter
- This checks the serial number or batch code fields
v39 -> 2022-04-18
- Adds ability to filter StockItem list by "has_batch" parameter
v38 -> 2022-04-14 : https://github.com/inventree/InvenTree/pull/2828
- Adds the ability to include stock test results for "installed items"
v37 -> 2022-04-07 : https://github.com/inventree/InvenTree/pull/2806
- Adds extra stock availability information to the BomItem serializer
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)
v33 -> 2022-03-24
- Adds "plugins_enabled" information to root API endpoint
v32 -> 2022-03-19
- Adds "parameters" detail to Part API endpoint (use &parameters=true)
- Adds ability to filter PartParameterTemplate API by Part instance
- Adds ability to filter PartParameterTemplate API by PartCategory instance
v31 -> 2022-03-14
- Adds "updated" field to SupplierPriceBreakList and SupplierPriceBreakDetail API endpoints
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
v27 -> 2022-02-28
- Adds target_date field to individual line items for purchase orders and sales orders
v26 -> 2022-02-17
- Adds API endpoint for uploading a BOM file and extracting data
v25 -> 2022-02-17
- Adds ability to filter "part" list endpoint by "in_bom_for" argument
v24 -> 2022-02-10
- Adds API endpoint for deleting (cancelling) build order outputs
v23 -> 2022-02-02
- Adds API endpoints for managing plugin classes
- Adds API endpoints for managing plugin settings
v22 -> 2021-12-20
- Adds API endpoint to "merge" multiple stock items
v21 -> 2021-12-04
- Adds support for multiple "Shipments" against a SalesOrder
- Refactors process for stock allocation against a SalesOrder
v20 -> 2021-12-03
- Adds ability to filter POLineItem endpoint by "base_part"
- Adds optional "order_detail" to POLineItem list endpoint
v19 -> 2021-12-02
- Adds the ability to filter the StockItem API by "part_tree"
- Returns only stock items which match a particular part.tree_id field
v18 -> 2021-11-15
- Adds the ability to filter BomItem API by "uses" field
- This returns a list of all BomItems which "use" the specified part
- Includes inherited BomItem objects
v17 -> 2021-11-09
- Adds API endpoints for GLOBAL and USER settings objects
- Ref: https://github.com/inventree/InvenTree/pull/2275
v16 -> 2021-10-17
- Adds API endpoint for completing build order outputs
v15 -> 2021-10-06
- Adds detail endpoint for SalesOrderAllocation model
- Allows use of the API forms interface for adjusting SalesOrderAllocation objects
v14 -> 2021-10-05
- Stock adjustment actions API is improved, using native DRF serializer support
- However adjustment actions now only support 'pk' as a lookup field
v13 -> 2021-10-05
- Adds API endpoint to allocate stock items against a BuildOrder
- Updates StockItem API with improved filtering against BomItem data
v12 -> 2021-09-07
- Adds API endpoint to receive stock items against a PurchaseOrder
v11 -> 2021-08-26
- Adds "units" field to PartBriefSerializer
- This allows units to be introspected from the "part_detail" field in the StockItem serializer
v10 -> 2021-08-23
- Adds "purchase_price_currency" to StockItem serializer
- Adds "purchase_price_string" to StockItem serializer
- Purchase price is now writable for StockItem serializer
v9 -> 2021-08-09
- Adds "price_string" to part pricing serializers
v8 -> 2021-07-19
- Refactors the API interface for SupplierPart and ManufacturerPart models
- ManufacturerPart objects can no longer be created via the SupplierPart API endpoint
v7 -> 2021-07-03
- Introduced the concept of "API forms" in https://github.com/inventree/InvenTree/pull/1716
- API OPTIONS endpoints provide comprehensive field metedata
- Multiple new API endpoints added for database models
v6 -> 2021-06-23
- Part and Company images can now be directly uploaded via the REST API
v5 -> 2021-06-21
- Adds API interface for manufacturer part parameters
v4 -> 2021-06-01
- BOM items can now accept "variant stock" to be assigned against them
- Many slight API tweaks were needed to get this to work properly!
v3 -> 2021-05-22:
- The updated StockItem "history tracking" now uses a different interface
"""

View File

@ -150,13 +150,13 @@ class DeleteForm(forms.Form):
class EditUserForm(HelperForm):
""" Form for editing user information
"""
Form for editing user information
"""
class Meta:
model = User
fields = [
'username',
'first_name',
'last_name',
]

View File

@ -427,8 +427,9 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
serials = serials.strip()
# fill in the next serial number into the serial
if '~' in serials:
serials = serials.replace('~', str(next_number))
while '~' in serials:
serials = serials.replace('~', str(next_number), 1)
next_number += 1
# Split input string by whitespace or comma (,) characters
groups = re.split("[\s,]+", serials)
@ -438,6 +439,12 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
# Helper function to check for duplicated numbers
def add_sn(sn):
# Attempt integer conversion first, so numerical strings are never stored
try:
sn = int(sn)
except ValueError:
pass
if sn in numbers:
errors.append(_('Duplicate serial: {sn}').format(sn=sn))
else:
@ -451,15 +458,25 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
if len(serials) == 0:
raise ValidationError([_("Empty serial number string")])
for group in groups:
# If the user has supplied the correct number of serials, don't process them for groups
# just add them so any duplicates (or future validations) are checked
if len(groups) == expected_quantity:
for group in groups:
add_sn(group)
if len(errors) > 0:
raise ValidationError(errors)
return numbers
for group in groups:
group = group.strip()
# Hyphen indicates a range of numbers
if '-' in group:
items = group.split('-')
if len(items) == 2:
if len(items) == 2 and all([i.isnumeric() for i in items]):
a = items[0].strip()
b = items[1].strip()
@ -471,13 +488,14 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
for n in range(a, b + 1):
add_sn(n)
else:
errors.append(_("Invalid group: {g}").format(g=group))
errors.append(_("Invalid group range: {g}").format(g=group))
except ValueError:
errors.append(_("Invalid group: {g}").format(g=group))
continue
else:
errors.append(_("Invalid group: {g}").format(g=group))
# More than 2 hyphens or non-numeric group so add without interpolating
add_sn(group)
# plus signals either
# 1: 'start+': expected number of serials, starting at start
@ -495,23 +513,17 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
# case 1
else:
end = start + expected_quantity
end = start + (expected_quantity - len(numbers))
for n in range(start, end):
add_sn(n)
# no case
else:
errors.append(_("Invalid group: {g}").format(g=group))
errors.append(_("Invalid group sequence: {g}").format(g=group))
# At this point, we assume that the "group" is just a single serial value
elif group:
try:
# First attempt to add as an integer value
add_sn(int(group))
except (ValueError):
# As a backup, add as a string value
add_sn(group)
add_sn(group)
# No valid input group detected
else:

View File

@ -1,11 +1,11 @@
from django.shortcuts import HttpResponseRedirect
from django.urls import reverse_lazy, Resolver404
from django.db import connection
from django.shortcuts import redirect
from django.conf.urls import include, url
from django.conf import settings
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
import logging
import time
import operator
from rest_framework.authtoken.models import Token
from allauth_2fa.middleware import BaseRequire2FAMiddleware, AllauthTwoFactorMiddleware
@ -92,67 +92,6 @@ class AuthRequiredMiddleware(object):
return response
class QueryCountMiddleware(object):
"""
This middleware will log the number of queries run
and the total time taken for each request (with a
status code of 200). It does not currently support
multi-db setups.
To enable this middleware, set 'log_queries: True' in the local InvenTree config file.
Reference: https://www.dabapps.com/blog/logging-sql-queries-django-13/
Note: 2020-08-15 - This is no longer used, instead we now rely on the django-debug-toolbar addon
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
t_start = time.time()
response = self.get_response(request)
t_stop = time.time()
if response.status_code == 200:
total_time = 0
if len(connection.queries) > 0:
queries = {}
for query in connection.queries:
query_time = query.get('time')
sql = query.get('sql').split('.')[0]
if sql in queries:
queries[sql] += 1
else:
queries[sql] = 1
if query_time is None:
# django-debug-toolbar monkeypatches the connection
# cursor wrapper and adds extra information in each
# item in connection.queries. The query time is stored
# under the key "duration" rather than "time" and is
# in milliseconds, not seconds.
query_time = float(query.get('duration', 0))
total_time += float(query_time)
logger.debug('{n} queries run, {a:.3f}s / {b:.3f}s'.format(
n=len(connection.queries),
a=total_time,
b=(t_stop - t_start)))
for x in sorted(queries.items(), key=operator.itemgetter(1), reverse=True):
print(x[0], ':', x[1])
return response
url_matcher = url('', include(frontendpatterns))
@ -176,3 +115,16 @@ class CustomAllauthTwoFactorMiddleware(AllauthTwoFactorMiddleware):
super().process_request(request)
except Resolver404:
pass
class InvenTreeRemoteUserMiddleware(PersistentRemoteUserMiddleware):
"""
Middleware to check if HTTP-header based auth is enabled and to set it up
"""
header = settings.REMOTE_LOGIN_HEADER
def process_request(self, request):
if not settings.REMOTE_LOGIN:
return
return super().process_request(request)

View File

@ -25,6 +25,7 @@ import moneyed
import yaml
from django.utils.translation import gettext_lazy as _
from django.contrib.messages import constants as messages
from django.core.files.storage import default_storage
import django.conf.locale
from .config import get_base_dir, get_config_file, get_plugin_file, get_setting
@ -61,12 +62,6 @@ DEBUG = _is_true(get_setting(
CONFIG.get('debug', True)
))
# Determine if we are running in "demo mode"
DEMO_MODE = _is_true(get_setting(
'INVENTREE_DEMO',
CONFIG.get('demo', False)
))
DOCKER = _is_true(get_setting(
'INVENTREE_DOCKER',
False
@ -216,9 +211,6 @@ MEDIA_URL = '/media/'
if DEBUG:
logger.info("InvenTree running with DEBUG enabled")
if DEMO_MODE:
logger.warning("InvenTree running in DEMO mode") # pragma: no cover
logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
@ -282,12 +274,14 @@ INSTALLED_APPS = [
MIDDLEWARE = CONFIG.get('middleware', [
'django.middleware.security.SecurityMiddleware',
'x_forwarded_for.middleware.XForwardedForMiddleware',
'user_sessions.middleware.SessionMiddleware', # db user sessions
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'InvenTree.middleware.InvenTreeRemoteUserMiddleware', # Remote / proxy auth
'django_otp.middleware.OTPMiddleware', # MFA support
'InvenTree.middleware.CustomAllauthTwoFactorMiddleware', # Flow control for allauth
'django.contrib.messages.middleware.MessageMiddleware',
@ -301,6 +295,7 @@ MIDDLEWARE = CONFIG.get('middleware', [
MIDDLEWARE.append('error_report.middleware.ExceptionProcessor')
AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [
'django.contrib.auth.backends.RemoteUserBackend', # proxy login
'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend', # SSO login via external providers
])
@ -549,11 +544,19 @@ if "sqlite" in db_engine:
# Provide OPTIONS dict back to the database configuration dict
db_config['OPTIONS'] = db_options
# Set testing options for the database
db_config['TEST'] = {
'CHARSET': 'utf8',
}
# Set collation option for mysql test database
if 'mysql' in db_engine:
db_config['TEST']['COLLATION'] = 'utf8_general_ci'
DATABASES = {
'default': db_config
}
_cache_config = CONFIG.get("cache", {})
_cache_host = _cache_config.get("host", os.getenv("INVENTREE_CACHE_HOST"))
_cache_port = _cache_config.get(
@ -666,11 +669,13 @@ LANGUAGE_CODE = CONFIG.get('language', 'en-us')
# If a new language translation is supported, it must be added here
LANGUAGES = [
('cs', _('Czech')),
('de', _('German')),
('el', _('Greek')),
('en', _('English')),
('es', _('Spanish')),
('es-mx', _('Spanish (Mexican)')),
('fa', _('Farsi / Persian')),
('fr', _('French')),
('he', _('Hebrew')),
('hu', _('Hungarian')),
@ -680,7 +685,8 @@ LANGUAGES = [
('nl', _('Dutch')),
('no', _('Norwegian')),
('pl', _('Polish')),
('pt', _('Portugese')),
('pt', _('Portuguese')),
('pt-BR', _('Portuguese (Brazilian)')),
('ru', _('Russian')),
('sv', _('Swedish')),
('th', _('Thai')),
@ -846,6 +852,10 @@ ACCOUNT_FORMS = {
SOCIALACCOUNT_ADAPTER = 'InvenTree.forms.CustomSocialAccountAdapter'
ACCOUNT_ADAPTER = 'InvenTree.forms.CustomAccountAdapter'
# login settings
REMOTE_LOGIN = get_setting('INVENTREE_REMOTE_LOGIN', CONFIG.get('remote_login', False))
REMOTE_LOGIN_HEADER = get_setting('INVENTREE_REMOTE_LOGIN_HEADER', CONFIG.get('remote_login_header', 'REMOTE_USER'))
# Markdownx configuration
# Ref: https://neutronx.github.io/django-markdownx/customization/
MARKDOWNX_MEDIA_PATH = datetime.now().strftime('markdownx/%Y/%m/%d')
@ -905,3 +915,20 @@ PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING) # are plugins beeing te
PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False) # load plugins from setup hooks in testing?
PLUGIN_RETRY = get_setting('PLUGIN_RETRY', 5) # how often should plugin loading be tried?
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
# User interface customization values
CUSTOMIZE = get_setting(
'INVENTREE_CUSTOMIZE',
CONFIG.get('customize', {}),
{}
)
CUSTOM_LOGO = get_setting(
'INVENTREE_CUSTOM_LOGO',
CUSTOMIZE.get('logo', False)
)
# check that the logo-file exsists in media
if CUSTOM_LOGO and not default_storage.exists(CUSTOM_LOGO):
CUSTOM_LOGO = False
logger.warning("The custom logo file could not be found in the default media storage")

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;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

@ -1,4 +1,4 @@
/*! jQuery UI - v1.12.1 - 2021-07-18
/*! jQuery UI - v1.13.0 - 2021-10-07
* http://jqueryui.com
* Includes: widget.js, position.js, disable-selection.js, keycode.js, unique-id.js, widgets/resizable.js, widgets/autocomplete.js, widgets/menu.js, widgets/mouse.js
* Copyright jQuery Foundation and other contributors; Licensed MIT */
@ -17,11 +17,11 @@
$.ui = $.ui || {};
var version = $.ui.version = "1.12.1";
var version = $.ui.version = "1.13.1";
/*!
* jQuery UI Widget 1.12.1
* jQuery UI Widget 1.13.0
* http://jqueryui.com
*
* Copyright jQuery Foundation and other contributors
@ -744,7 +744,7 @@ var widget = $.widget;
/*!
* jQuery UI Position 1.12.1
* jQuery UI Position 1.13.1
* http://jqueryui.com
*
* Copyright jQuery Foundation and other contributors
@ -1232,7 +1232,7 @@ var position = $.ui.position;
/*!
* jQuery UI Disable Selection 1.12.1
* jQuery UI Disable Selection 1.13.0
* http://jqueryui.com
*
* Copyright jQuery Foundation and other contributors
@ -1268,7 +1268,7 @@ var disableSelection = $.fn.extend( {
/*!
* jQuery UI Keycode 1.12.1
* jQuery UI Keycode 1.13.0
* http://jqueryui.com
*
* Copyright jQuery Foundation and other contributors
@ -1303,7 +1303,7 @@ var keycode = $.ui.keyCode = {
/*!
* jQuery UI Unique ID 1.12.1
* jQuery UI Unique ID 1.13.0
* http://jqueryui.com
*
* Copyright jQuery Foundation and other contributors
@ -1347,7 +1347,7 @@ var uniqueId = $.fn.extend( {
var ie = $.ui.ie = !!/msie [\w.]+/.exec( navigator.userAgent.toLowerCase() );
/*!
* jQuery UI Mouse 1.12.1
* jQuery UI Mouse 1.13.0
* http://jqueryui.com
*
* Copyright jQuery Foundation and other contributors
@ -1368,7 +1368,7 @@ $( document ).on( "mouseup", function() {
} );
var widgetsMouse = $.widget( "ui.mouse", {
version: "1.12.1",
version: "1.13.0",
options: {
cancel: "input, textarea, button, select, option",
distance: 1,
@ -1592,7 +1592,7 @@ var plugin = $.ui.plugin = {
/*!
* jQuery UI Resizable 1.12.1
* jQuery UI Resizable 1.13.0
* http://jqueryui.com
*
* Copyright jQuery Foundation and other contributors
@ -1612,7 +1612,7 @@ var plugin = $.ui.plugin = {
$.widget( "ui.resizable", $.ui.mouse, {
version: "1.12.1",
version: "1.13.0",
widgetEventPrefix: "resize",
options: {
alsoResize: false,
@ -2806,7 +2806,7 @@ var safeActiveElement = $.ui.safeActiveElement = function( document ) {
/*!
* jQuery UI Menu 1.12.1
* jQuery UI Menu 1.13.0
* http://jqueryui.com
*
* Copyright jQuery Foundation and other contributors
@ -2826,7 +2826,7 @@ var safeActiveElement = $.ui.safeActiveElement = function( document ) {
var widgetsMenu = $.widget( "ui.menu", {
version: "1.12.1",
version: "1.13.0",
defaultElement: "<ul>",
delay: 300,
options: {
@ -3461,7 +3461,7 @@ var widgetsMenu = $.widget( "ui.menu", {
/*!
* jQuery UI Autocomplete 1.12.1
* jQuery UI Autocomplete 1.13.0
* http://jqueryui.com
*
* Copyright jQuery Foundation and other contributors
@ -3481,7 +3481,7 @@ var widgetsMenu = $.widget( "ui.menu", {
$.widget( "ui.autocomplete", {
version: "1.12.1",
version: "1.13.0",
defaultElement: "<input>",
options: {
appendTo: null,

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,7 @@
"name": "jquery-ui",
"title": "jQuery UI",
"description": "A curated set of user interface interactions, effects, widgets, and themes built on top of the jQuery JavaScript Library.",
"version": "1.12.1",
"version": "1.13.0",
"homepage": "http://jqueryui.com",
"author": {
"name": "jQuery Foundation and other contributors",

View File

@ -255,6 +255,9 @@ class StockHistoryCode(StatusCode):
# Stock merging operations
MERGED_STOCK_ITEMS = 45
# Convert stock item to variant
CONVERTED_TO_VARIANT = 48
# Build order codes
BUILD_OUTPUT_CREATED = 50
BUILD_OUTPUT_COMPLETED = 55
@ -294,6 +297,8 @@ class StockHistoryCode(StatusCode):
MERGED_STOCK_ITEMS: _('Merged stock items'),
CONVERTED_TO_VARIANT: _('Converted to variant'),
SENT_TO_CUSTOMER: _('Sent to customer'),
RETURNED_FROM_CUSTOMER: _('Returned from customer'),

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 = 39
content = self.get_index_page()

View File

@ -252,6 +252,31 @@ class TestSerialNumberExtraction(TestCase):
sn = e("1, 2, 3, 4, 5", 5, 1)
self.assertEqual(len(sn), 5)
# Test partially specifying serials
sn = e("1, 2, 4+", 5, 1)
self.assertEqual(len(sn), 5)
self.assertEqual(sn, [1, 2, 4, 5, 6])
# Test groups are not interpolated if enough serials are supplied
sn = e("1, 2, 3, AF5-69H, 5", 5, 1)
self.assertEqual(len(sn), 5)
self.assertEqual(sn, [1, 2, 3, "AF5-69H", 5])
# Test groups are not interpolated with more than one hyphen in a word
sn = e("1, 2, TG-4SR-92, 4+", 5, 1)
self.assertEqual(len(sn), 5)
self.assertEqual(sn, [1, 2, "TG-4SR-92", 4, 5])
# Test groups are not interpolated with alpha characters
sn = e("1, A-2, 3+", 5, 1)
self.assertEqual(len(sn), 5)
self.assertEqual(sn, [1, "A-2", 3, 4, 5])
# Test multiple placeholders
sn = e("1 2 ~ ~ ~", 5, 3)
self.assertEqual(len(sn), 5)
self.assertEqual(sn, [1, 2, 3, 4, 5])
sn = e("1-5, 10-15", 11, 1)
self.assertIn(3, sn)
self.assertIn(13, sn)
@ -307,6 +332,10 @@ class TestSerialNumberExtraction(TestCase):
with self.assertRaises(ValidationError):
e("10, a, 7-70j", 4, 1)
# Test groups are not interpolated with word characters
with self.assertRaises(ValidationError):
e("1, 2, 3, E-5", 5, 1)
def test_combinations(self):
e = helpers.extract_serial_numbers

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

@ -1,4 +1,5 @@
""" Version information for InvenTree.
"""
Version information for InvenTree.
Provides information on the current InvenTree version
"""
@ -8,134 +9,11 @@ import re
import common.models
from InvenTree.api_version import INVENTREE_API_VERSION
# InvenTree software version
INVENTREE_SW_VERSION = "0.7.0 dev"
# InvenTree API version
INVENTREE_API_VERSION = 34
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v34 -> 2022-03-25
- Change permissions for "plugin list" API endpoint (now allows any authenticated user)
v33 -> 2022-03-24
- Adds "plugins_enabled" information to root API endpoint
v32 -> 2022-03-19
- Adds "parameters" detail to Part API endpoint (use &parameters=true)
- Adds ability to filter PartParameterTemplate API by Part instance
- Adds ability to filter PartParameterTemplate API by PartCategory instance
v31 -> 2022-03-14
- Adds "updated" field to SupplierPriceBreakList and SupplierPriceBreakDetail API endpoints
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
v27 -> 2022-02-28
- Adds target_date field to individual line items for purchase orders and sales orders
v26 -> 2022-02-17
- Adds API endpoint for uploading a BOM file and extracting data
v25 -> 2022-02-17
- Adds ability to filter "part" list endpoint by "in_bom_for" argument
v24 -> 2022-02-10
- Adds API endpoint for deleting (cancelling) build order outputs
v23 -> 2022-02-02
- Adds API endpoints for managing plugin classes
- Adds API endpoints for managing plugin settings
v22 -> 2021-12-20
- Adds API endpoint to "merge" multiple stock items
v21 -> 2021-12-04
- Adds support for multiple "Shipments" against a SalesOrder
- Refactors process for stock allocation against a SalesOrder
v20 -> 2021-12-03
- Adds ability to filter POLineItem endpoint by "base_part"
- Adds optional "order_detail" to POLineItem list endpoint
v19 -> 2021-12-02
- Adds the ability to filter the StockItem API by "part_tree"
- Returns only stock items which match a particular part.tree_id field
v18 -> 2021-11-15
- Adds the ability to filter BomItem API by "uses" field
- This returns a list of all BomItems which "use" the specified part
- Includes inherited BomItem objects
v17 -> 2021-11-09
- Adds API endpoints for GLOBAL and USER settings objects
- Ref: https://github.com/inventree/InvenTree/pull/2275
v16 -> 2021-10-17
- Adds API endpoint for completing build order outputs
v15 -> 2021-10-06
- Adds detail endpoint for SalesOrderAllocation model
- Allows use of the API forms interface for adjusting SalesOrderAllocation objects
v14 -> 2021-10-05
- Stock adjustment actions API is improved, using native DRF serializer support
- However adjustment actions now only support 'pk' as a lookup field
v13 -> 2021-10-05
- Adds API endpoint to allocate stock items against a BuildOrder
- Updates StockItem API with improved filtering against BomItem data
v12 -> 2021-09-07
- Adds API endpoint to receive stock items against a PurchaseOrder
v11 -> 2021-08-26
- Adds "units" field to PartBriefSerializer
- This allows units to be introspected from the "part_detail" field in the StockItem serializer
v10 -> 2021-08-23
- Adds "purchase_price_currency" to StockItem serializer
- Adds "purchase_price_string" to StockItem serializer
- Purchase price is now writable for StockItem serializer
v9 -> 2021-08-09
- Adds "price_string" to part pricing serializers
v8 -> 2021-07-19
- Refactors the API interface for SupplierPart and ManufacturerPart models
- ManufacturerPart objects can no longer be created via the SupplierPart API endpoint
v7 -> 2021-07-03
- Introduced the concept of "API forms" in https://github.com/inventree/InvenTree/pull/1716
- API OPTIONS endpoints provide comprehensive field metedata
- Multiple new API endpoints added for database models
v6 -> 2021-06-23
- Part and Company images can now be directly uploaded via the REST API
v5 -> 2021-06-21
- Adds API interface for manufacturer part parameters
v4 -> 2021-06-01
- BOM items can now accept "variant stock" to be assigned against them
- Many slight API tweaks were needed to get this to work properly!
v3 -> 2021-05-22:
- The updated StockItem "history tracking" now uses a different interface
"""
def inventreeInstanceName():
""" Returns the InstanceName settings for the current database """
@ -196,7 +74,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

@ -34,8 +34,7 @@ from user_sessions.views import SessionDeleteView, SessionDeleteOtherView
from common.settings import currency_code_default, currency_codes
from part.models import Part, PartCategory
from stock.models import StockLocation, StockItem
from part.models import PartCategory
from common.models import InvenTreeSetting, ColorTheme
from users.models import check_user_role, RuleSet
@ -882,29 +881,6 @@ class DatabaseStatsView(AjaxView):
ajax_template_name = "stats.html"
ajax_form_title = _("System Information")
def get_context_data(self, **kwargs):
ctx = {}
# Part stats
ctx['part_count'] = Part.objects.count()
ctx['part_cat_count'] = PartCategory.objects.count()
# Stock stats
ctx['stock_item_count'] = StockItem.objects.count()
ctx['stock_loc_count'] = StockLocation.objects.count()
"""
TODO: Other ideas for database metrics
- "Popular" parts (used to make other parts?)
- Most ordered part
- Most sold part
- etc etc etc
"""
return ctx
class NotificationsView(TemplateView):
""" View for showing notifications

View File

@ -871,6 +871,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
part__in=[p for p in available_parts],
)
# Filter out "serialized" stock items, these cannot be auto-allocated
available_stock = available_stock.filter(Q(serial=None) | Q(serial=''))
if location:
# Filter only stock items located "below" the specified location
sublocations = location.get_descendants(include_self=True)

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

@ -3,7 +3,6 @@
{% load i18n %}
{% load inventree_extras %}
{% load status_codes %}
{% load markdownify %}
{% block sidebar %}
{% include "build/sidebar.html" %}
@ -309,24 +308,16 @@
<div class='panel panel-hidden' id='panel-notes'>
<div class='panel-heading'>
<div class='row'>
<div class='col-sm-6'>
<h4>{% trans "Build Notes" %}</h4>
</div>
<div class='col-sm-6'>
<div class='btn-group float-right'>
<button type='button' id='edit-notes' title='{% trans "Edit Notes" %}' class='btn btn-small btn-outline-secondary'>
<span class='fas fa-edit'>
</span>
</button>
</div>
<div class='d-flex flex-wrap'>
<h4>{% trans "Build Notes" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% include "notes_buttons.html" %}
</div>
</div>
</div>
<div class='panel-content'>
{% if build.notes %}
{{ build.notes | markdownify }}
{% endif %}
<textarea id='build-notes'></textarea>
</div>
</div>
@ -392,17 +383,18 @@ onPanelLoad('attachments', function() {
});
onPanelLoad('notes', function() {
$('#edit-notes').click(function() {
constructForm('{% url "api-build-detail" build.pk %}', {
fields: {
notes: {
multiline: true,
}
},
title: '{% trans "Edit Notes" %}',
reload: true,
});
});
setupNotesField(
'build-notes',
'{% url "api-build-detail" build.pk %}',
{
{% if roles.build.change %}
editable: true,
{% else %}
editable: false,
{% endif %}
}
);
});
function reloadTable() {

View File

@ -684,7 +684,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
},
'INVENTREE_INSTANCE': {
'name': _('InvenTree Instance Name'),
'name': _('Server Instance Name'),
'default': 'InvenTree server',
'description': _('String descriptor for the server instance'),
},
@ -696,6 +696,13 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
},
'INVENTREE_RESTRICT_ABOUT': {
'name': _('Restrict showing `about`'),
'description': _('Show the `about` modal only to superusers'),
'validator': bool,
'default': False,
},
'INVENTREE_COMPANY_NAME': {
'name': _('Company name'),
'description': _('Internal company name'),
@ -932,6 +939,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool,
},
'STOCK_BATCH_CODE_TEMPLATE': {
'name': _('Batch Code Template'),
'description': _('Template for generating default batch codes for stock items'),
'default': '',
},
'STOCK_ENABLE_EXPIRY': {
'name': _('Stock Expiry'),
'description': _('Enable stock expiry functionality'),
@ -1247,6 +1260,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 +1281,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'),
@ -1298,7 +1360,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'STICKY_HEADER': {
'name': _('Fixed Navbar'),
'description': _('InvenTree navbar position is fixed to the top of the screen'),
'description': _('The navbar position is fixed to the top of the screen'),
'default': False,
'validator': bool,
},

View File

@ -63,8 +63,8 @@ class SettingsTest(TestCase):
report_test_obj = InvenTreeSetting.get_setting_object('REPORT_ENABLE_TEST_REPORT')
# check settings base fields
self.assertEqual(instance_obj.name, 'InvenTree Instance Name')
self.assertEqual(instance_obj.get_setting_name(instance_ref), 'InvenTree Instance Name')
self.assertEqual(instance_obj.name, 'Server Instance Name')
self.assertEqual(instance_obj.get_setting_name(instance_ref), 'Server Instance Name')
self.assertEqual(instance_obj.description, 'String descriptor for the server instance')
self.assertEqual(instance_obj.get_setting_description(instance_ref), 'String descriptor for the server instance')

View File

@ -1,7 +1,6 @@
{% extends "company/company_base.html" %}
{% load static %}
{% load i18n %}
{% load markdownify %}
{% block sidebar %}
{% include 'company/sidebar.html' %}
@ -181,24 +180,16 @@
<div class='panel panel-hidden' id='panel-company-notes'>
<div class='panel-heading'>
<div class='row'>
<div class='col-sm-6'>
<h4>{% trans "Company Notes" %}</h4>
</div>
<div class='col-sm-6'>
<div class='btn-group float-right'>
<button type='button' id='edit-notes' title='{% trans "Edit Notes" %}' class='btn btn-small btn-outline-secondary'>
<span class='fas fa-edit'>
</span>
</button>
</div>
<div class='d-flex flex-wrap'>
<h4>{% trans "Company Notes" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% include "notes_buttons.html" %}
</div>
</div>
</div>
<div class='panel-content'>
{% if company.notes %}
{{ company.notes | markdownify }}
{% endif %}
<textarea id='company-notes'></textarea>
</div>
</div>
@ -207,16 +198,15 @@
{% block js_ready %}
{{ block.super }}
$('#edit-notes').click(function() {
constructForm('{% url "api-company-detail" company.pk %}', {
fields: {
notes: {
multiline: true,
}
},
title: '{% trans "Edit Notes" %}',
reload: true,
});
onPanelLoad('company-notes', function() {
setupNotesField(
'company-notes',
'{% url "api-company-detail" company.pk %}',
{
editable: true,
}
)
});
loadStockTable($("#assigned-stock-table"), {
@ -230,18 +220,37 @@
filterTarget: '#filter-list-customerstock',
});
{% if company.is_customer %}
loadSalesOrderTable("#sales-order-table", {
url: "{% url 'api-so-list' %}",
params: {
customer: {{ company.id }},
}
onPanelLoad('company-stock', function() {
loadStockTable($('#stock-table'), {
url: "{% url 'api-stock-list' %}",
params: {
company: {{ company.id }},
part_detail: true,
supplier_part_detail: true,
location_detail: true,
},
buttons: [
'#stock-options',
],
filterKey: "companystock",
});
});
$("#new-sales-order").click(function() {
{% if company.is_customer %}
onPanelLoad('sales-orders', function() {
loadSalesOrderTable("#sales-order-table", {
url: "{% url 'api-so-list' %}",
params: {
customer: {{ company.id }},
}
});
createSalesOrder({
customer: {{ company.pk }},
$("#new-sales-order").click(function() {
createSalesOrder({
customer: {{ company.pk }},
});
});
});
{% endif %}
@ -270,20 +279,6 @@
{% endif %}
loadStockTable($('#stock-table'), {
url: "{% url 'api-stock-list' %}",
params: {
company: {{ company.id }},
part_detail: true,
supplier_part_detail: true,
location_detail: true,
},
buttons: [
'#stock-options',
],
filterKey: "companystock",
});
{% if company.is_manufacturer %}
function reloadManufacturerPartTable() {

View File

@ -1,9 +1,10 @@
{% extends "page_base.html" %}
{% load static %}
{% load i18n %}
{% load inventree_extras %}
{% block page_title %}
InvenTree | {% trans "Manufacturer Part" %}
{% inventree_title %} | {% trans "Manufacturer Part" %}
{% endblock %}
{% block sidebar %}

View File

@ -154,6 +154,14 @@ static_root: '/home/inventree/data/static'
# Use environment variable INVENTREE_LOGIN_ATTEMPTS
#login_attempts: 5
# Remote / proxy login
# These settings can introduce security problems if configured incorrectly. Please read
# https://docs.djangoproject.com/en/4.0/howto/auth-remote-user/ for more details
# Use environment variable INVENTREE_REMOTE_LOGIN
# remote_login: True
# Use environment variable INVENTREE_REMOTE_LOGIN_HEADER
# remote_login_header: REMOTE_USER
# Add new user on first startup
#admin_user: admin
#admin_email: info@example.com
@ -186,3 +194,12 @@ static_root: '/home/inventree/data/static'
# KEYCLOAK_URL: 'https://keycloak.custom/auth'
# KEYCLOAK_REALM: 'master'
# Customization options
# Add custom messages to the login page or main interface navbar or exchange the logo
# Use environment variable INVENTREE_CUSTOMIZE or INVENTREE_CUSTOM_LOGO
# customize:
# login_message: InvenTree demo instance - <a href='https://inventree.readthedocs.io/en/latest/demo/'> Click here for login details</a>
# navbar_message: <h6>InvenTree demo mode <a href='https://inventree.readthedocs.io/en/latest/demo/'><span class='fas fa-info-circle'></span></a></h6>
# logo: logo.png
# hide_admin_link: true
# hide_password_reset: true

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

@ -11,10 +11,14 @@
{% block page_content %}
{% trans "Upload File for Purchase Order" as header_text %}
{% order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change as upload_go_ahead %}
{% trans "Order is already processed. Files cannot be uploaded." as error_text %}
{% "panel-upload-file" as panel_id %}
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=upload_go_ahead error_text=error_text panel_id=panel_id %}
{% with "panel-upload-file" as panel_id %}
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=True error_text=error_text panel_id=panel_id %}
{% else %}
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=False error_text=error_text panel_id=panel_id %}
{% endif %}
{% endwith %}
{% endblock %}
{% block js_ready %}

View File

@ -4,7 +4,6 @@
{% load status_codes %}
{% load i18n %}
{% load static %}
{% load markdownify %}
{% block sidebar %}
{% include 'order/po_sidebar.html' %}
@ -71,24 +70,16 @@
<div class='panel panel-hidden' id='panel-order-notes'>
<div class='panel-heading'>
<div class='row'>
<div class='col-sm-6'>
<h4>{% trans "Order Notes" %}</h4>
</div>
<div class='col-sm-6'>
<div class='btn-group float-right'>
<button type='button' id='edit-notes' title='{% trans "Edit Notes" %}' class='btn btn-outline-secondary'>
<span class='fas fa-edit'>
</span>
</button>
</div>
<div class='d-flex flex-wrap'>
<h4>{% trans "Order Notes" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% include "notes_buttons.html" %}
</div>
</div>
</div>
<div class='panel-content'>
{% if order.notes %}
{{ order.notes | markdownify }}
{% endif %}
<textarea id='order-notes'></textarea>
</div>
</div>
@ -98,16 +89,18 @@
{{ block.super }}
$('#edit-notes').click(function() {
constructForm('{% url "api-po-detail" order.pk %}', {
fields: {
notes: {
multiline: true,
}
},
title: '{% trans "Edit Notes" %}',
reload: true,
});
onPanelLoad('order-notes', function() {
setupNotesField(
'order-notes',
'{% url "api-po-detail" order.pk %}',
{
{% if roles.purchase_order.change %}
editable: true,
{% else %}
editable: false,
{% endif %}
}
);
});
enableDragAndDrop(

View File

@ -4,7 +4,6 @@
{% load status_codes %}
{% load i18n %}
{% load static %}
{% load markdownify %}
{% block sidebar %}
{% include "order/so_sidebar.html" %}
@ -118,24 +117,16 @@
<div class='panel panel-hidden' id='panel-order-notes'>
<div class='panel-heading'>
<div class='row'>
<div class='col-sm-6'>
<h4>{% trans "Order Notes" %}</h4>
</div>
<div class='col-sm-6'>
<div class='btn-group float-right'>
<button type='button' id='edit-notes' title='{% trans "Edit Notes" %}' class='btn outline-secondary'>
<span class='fas fa-edit'>
</span>
</button>
</div>
<div class='d-flex flex-wrap'>
<h4>{% trans "Order Notes" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% include "notes_buttons.html" %}
</div>
</div>
</div>
<div class='panel-content'>
{% if order.notes %}
{{ order.notes | markdownify }}
{% endif %}
<textarea id='order-notes'></textarea>
</div>
</div>
@ -176,16 +167,18 @@
});
});
$('#edit-notes').click(function() {
constructForm('{% url "api-so-detail" order.pk %}', {
fields: {
notes: {
multiline: true,
}
},
title: '{% trans "Edit Notes" %}',
reload: true,
});
onPanelLoad('order-notes', function() {
setupNotesField(
'order-notes',
'{% url "api-so-detail" order.pk %}',
{
{% if roles.purchase_order.change %}
editable: true,
{% else %}
editable: false,
{% endif %}
}
);
});
enableDragAndDrop(

View File

@ -262,6 +262,15 @@ class CategoryTree(generics.ListAPIView):
ordering = ['level', 'name']
class PartSalePriceDetail(generics.RetrieveUpdateDestroyAPIView):
"""
Detail endpoint for PartSellPriceBreak model
"""
queryset = PartSellPriceBreak.objects.all()
serializer_class = part_serializers.PartSalePriceSerializer
class PartSalePriceList(generics.ListCreateAPIView):
"""
API endpoint for list view of PartSalePriceBreak model
@ -279,6 +288,15 @@ class PartSalePriceList(generics.ListCreateAPIView):
]
class PartInternalPriceDetail(generics.RetrieveUpdateDestroyAPIView):
"""
Detail endpoint for PartInternalPriceBreak model
"""
queryset = PartInternalPriceBreak.objects.all()
serializer_class = part_serializers.PartInternalPriceSerializer
class PartInternalPriceList(generics.ListCreateAPIView):
"""
API endpoint for list view of PartInternalPriceBreak model
@ -798,6 +816,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()
@ -1161,6 +1193,18 @@ class PartList(generics.ListCreateAPIView):
except (ValueError, Part.DoesNotExist):
pass
# Filter by 'variant_of'
# Note that this is subtly different from 'ancestor' filter (above)
variant_of = params.get('variant_of', None)
if variant_of is not None:
try:
template = Part.objects.get(pk=variant_of)
variants = template.get_children()
queryset = queryset.filter(pk__in=[v.pk for v in variants])
except (ValueError, Part.DoesNotExist):
pass
# Filter only parts which are in the "BOM" for a given part
in_bom_for = params.get('in_bom_for', None)
@ -1325,15 +1369,12 @@ class PartList(generics.ListCreateAPIView):
filters.OrderingFilter,
]
filter_fields = [
'variant_of',
]
ordering_fields = [
'name',
'creation_date',
'IPN',
'in_stock',
'unallocated_stock',
'category',
]
@ -1587,9 +1628,10 @@ class BomList(generics.ListCreateAPIView):
def get_queryset(self, *args, **kwargs):
queryset = BomItem.objects.all()
queryset = super().get_queryset(*args, **kwargs)
queryset = self.get_serializer_class().setup_eager_loading(queryset)
queryset = self.get_serializer_class().annotate_queryset(queryset)
return queryset
@ -1803,6 +1845,15 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = BomItem.objects.all()
serializer_class = part_serializers.BomItemSerializer
def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs)
queryset = self.get_serializer_class().setup_eager_loading(queryset)
queryset = self.get_serializer_class().annotate_queryset(queryset)
return queryset
class BomItemValidate(generics.UpdateAPIView):
""" API endpoint for validating a BomItem """
@ -1887,11 +1938,13 @@ part_api_urls = [
# Base URL for part sale pricing
url(r'^sale-price/', include([
url(r'^(?P<pk>\d+)/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'),
url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
])),
# Base URL for part internal pricing
url(r'^internal-price/', include([
url(r'^(?P<pk>\d+)/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'),
url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
])),

View File

@ -7,6 +7,7 @@
part: 100
sub_part: 1
quantity: 10
allow_variants: True
# 40 x R_2K2_0805
- model: part.bomitem
@ -38,3 +39,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
@ -165,6 +177,7 @@
fields:
name: 'Green chair variant'
variant_of: 10003
is_template: true
category: 7
trackable: true
tree_id: 1

View File

@ -777,7 +777,8 @@ class Part(MPTTModel):
# User can decide whether duplicate IPN (Internal Part Number) values are allowed
allow_duplicate_ipn = common.models.InvenTreeSetting.get_setting('PART_ALLOW_DUPLICATE_IPN')
if self.IPN is not None and not allow_duplicate_ipn:
# Raise an error if an IPN is set, and it is a duplicate
if self.IPN and not allow_duplicate_ipn:
parts = Part.objects.filter(IPN__iexact=self.IPN)
parts = parts.exclude(pk=self.pk)
@ -798,6 +799,10 @@ class Part(MPTTModel):
super().clean()
# Strip IPN field
if type(self.IPN) is str:
self.IPN = self.IPN.strip()
if self.trackable:
for part in self.get_used_in().all():
@ -1313,19 +1318,31 @@ class Part(MPTTModel):
return quantity
def build_order_allocations(self):
def build_order_allocations(self, **kwargs):
"""
Return all 'BuildItem' objects which allocate this part to Build objects
"""
return BuildModels.BuildItem.objects.filter(stock_item__part__id=self.id)
include_variants = kwargs.get('include_variants', True)
def build_order_allocation_count(self):
queryset = BuildModels.BuildItem.objects.all()
if include_variants:
variants = self.get_descendants(include_self=True)
queryset = queryset.filter(
stock_item__part__in=variants,
)
else:
queryset = queryset.filter(stock_item__part=self)
return queryset
def build_order_allocation_count(self, **kwargs):
"""
Return the total amount of this part allocated to build orders
"""
query = self.build_order_allocations().aggregate(
query = self.build_order_allocations(**kwargs).aggregate(
total=Coalesce(
Sum(
'quantity',
@ -1343,9 +1360,22 @@ class Part(MPTTModel):
Return all sales-order-allocation objects which allocate this part to a SalesOrder
"""
queryset = OrderModels.SalesOrderAllocation.objects.filter(item__part__id=self.id)
include_variants = kwargs.get('include_variants', True)
pending = kwargs.get('pending', None)
queryset = OrderModels.SalesOrderAllocation.objects.all()
if include_variants:
# Include allocations for all variants
variants = self.get_descendants(include_self=True)
queryset = queryset.filter(
item__part__in=variants,
)
else:
# Only look at this part
queryset = queryset.filter(item__part=self)
# 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
@ -1380,7 +1410,7 @@ class Part(MPTTModel):
return query['total']
def allocation_count(self):
def allocation_count(self, **kwargs):
"""
Return the total quantity of stock allocated for this part,
against both build orders and sales orders.
@ -1388,8 +1418,8 @@ class Part(MPTTModel):
return sum(
[
self.build_order_allocation_count(),
self.sales_order_allocation_count(),
self.build_order_allocation_count(**kwargs),
self.sales_order_allocation_count(**kwargs),
],
)
@ -1433,7 +1463,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):
"""
@ -2702,7 +2732,21 @@ class BomItem(models.Model, DataImportMixin):
for sub in self.substitutes.all():
parts.add(sub.part)
return parts
valid_parts = []
for p in parts:
# Inactive parts cannot be 'auto allocated'
if not p.active:
continue
# Trackable status must be the same as the sub_part
if p.trackable != self.sub_part.trackable:
continue
valid_parts.append(p)
return valid_parts
def is_stock_item_valid(self, stock_item):
"""
@ -2881,23 +2925,6 @@ class BomItem(models.Model, DataImportMixin):
child=self.sub_part.full_name,
n=decimal2string(self.quantity))
def available_stock(self):
"""
Return the available stock items for the referenced sub_part
"""
query = self.sub_part.stock_items.all()
query = query.prefetch_related([
'sub_part__stock_items',
])
query = query.filter(StockModels.StockItem.IN_STOCK_FILTER).aggregate(
available=Coalesce(Sum('quantity'), 0)
)
return query['available']
def get_overage_quantity(self, quantity):
""" Calculate overage quantity
"""

View File

@ -7,7 +7,9 @@ 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, Func
from django.db.models import Subquery, OuterRef, FloatField
from django.db.models.functions import Coalesce
from django.utils.translation import ugettext_lazy as _
@ -15,6 +17,8 @@ from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum
from djmoney.contrib.django_rest_framework import MoneyField
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.serializers import (DataFileUploadSerializer,
DataFileExtractSerializer,
InvenTreeAttachmentSerializerField,
@ -24,7 +28,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,
@ -143,6 +150,13 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
allow_null=True
)
price_currency = serializers.ChoiceField(
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
help_text=_('Purchase currency of this stock item'),
)
price_string = serializers.CharField(source='price', read_only=True)
class Meta:
@ -152,6 +166,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
'part',
'quantity',
'price',
'price_currency',
'price_string',
]
@ -167,6 +182,13 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
allow_null=True
)
price_currency = serializers.ChoiceField(
choices=currency_code_mappings(),
default=currency_code_default,
label=_('Currency'),
help_text=_('Purchase currency of this stock item'),
)
price_string = serializers.CharField(source='price', read_only=True)
class Meta:
@ -176,6 +198,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
'part',
'quantity',
'price',
'price_currency',
'price_string',
]
@ -305,9 +328,6 @@ class PartSerializer(InvenTreeModelSerializer):
to reduce database trips.
"""
# TODO: Update the "in_stock" annotation to include stock for variants of the part
# Ref: https://github.com/inventree/InvenTree/issues/2240
# Annotate with the total 'in stock' quantity
queryset = queryset.annotate(
in_stock=Coalesce(
@ -322,6 +342,24 @@ class PartSerializer(InvenTreeModelSerializer):
stock_item_count=SubqueryCount('stock_items')
)
# Annotate with the total variant stock quantity
variant_query = StockItem.objects.filter(
part__tree_id=OuterRef('tree_id'),
part__lft__gt=OuterRef('lft'),
part__rght__lt=OuterRef('rght'),
).filter(StockItem.IN_STOCK_FILTER)
queryset = queryset.annotate(
variant_stock=Coalesce(
Subquery(
variant_query.annotate(
total=Func(F('quantity'), function='SUM', output_field=FloatField())
).values('total')),
0,
output_field=FloatField(),
)
)
# Filter to limit builds to "active"
build_filter = Q(
status__in=BuildStatus.ACTIVE_CODES
@ -363,6 +401,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 +459,13 @@ class PartSerializer(InvenTreeModelSerializer):
category_detail = CategorySerializer(source='category', many=False, read_only=True)
# Calculated fields
in_stock = serializers.FloatField(read_only=True)
ordering = serializers.FloatField(read_only=True)
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)
variant_stock = serializers.FloatField(read_only=True)
ordering = serializers.FloatField(read_only=True)
stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True)
@ -399,7 +486,8 @@ class PartSerializer(InvenTreeModelSerializer):
partial = True
fields = [
'active',
'allocated_to_build_orders',
'allocated_to_sales_orders',
'assembly',
'category',
'category_detail',
@ -411,6 +499,7 @@ class PartSerializer(InvenTreeModelSerializer):
'full_name',
'image',
'in_stock',
'variant_stock',
'ordering',
'building',
'IPN',
@ -430,6 +519,7 @@ class PartSerializer(InvenTreeModelSerializer):
'suppliers',
'thumbnail',
'trackable',
'unallocated_stock',
'units',
'variant_of',
'virtual',
@ -524,6 +614,11 @@ class BomItemSerializer(InvenTreeModelSerializer):
purchase_price_range = serializers.SerializerMethodField()
# Annotated fields for available stock
available_stock = serializers.FloatField(read_only=True)
available_substitute_stock = serializers.FloatField(read_only=True)
available_variant_stock = serializers.FloatField(read_only=True)
def __init__(self, *args, **kwargs):
# part_detail and sub_part_detail serializers are only included if requested.
# This saves a bunch of database requests
@ -556,10 +651,158 @@ class BomItemSerializer(InvenTreeModelSerializer):
queryset = queryset.prefetch_related('sub_part')
queryset = queryset.prefetch_related('sub_part__category')
queryset = queryset.prefetch_related('sub_part__stock_items')
queryset = queryset.prefetch_related(
'sub_part__stock_items',
'sub_part__stock_items__allocations',
'sub_part__stock_items__sales_order_allocations',
)
queryset = queryset.prefetch_related(
'substitutes',
'substitutes__part__stock_items',
)
queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
return queryset
@staticmethod
def annotate_queryset(queryset):
"""
Annotate the BomItem queryset with extra information:
Annotations:
available_stock: The amount of stock available for the sub_part Part object
"""
"""
Construct an "available stock" quantity:
available_stock = total_stock - build_order_allocations - sales_order_allocations
"""
build_order_filter = Q(build__status__in=BuildStatus.ACTIVE_CODES)
sales_order_filter = Q(
line__order__status__in=SalesOrderStatus.OPEN,
shipment__shipment_date=None,
)
# Calculate "total stock" for the referenced sub_part
# Calculate the "build_order_allocations" for the sub_part
# Note that these fields are only aliased, not annotated
queryset = queryset.alias(
total_stock=Coalesce(
SubquerySum(
'sub_part__stock_items__quantity',
filter=StockItem.IN_STOCK_FILTER
),
Decimal(0),
output_field=models.DecimalField(),
),
allocated_to_sales_orders=Coalesce(
SubquerySum(
'sub_part__stock_items__sales_order_allocations__quantity',
filter=sales_order_filter,
),
Decimal(0),
output_field=models.DecimalField(),
),
allocated_to_build_orders=Coalesce(
SubquerySum(
'sub_part__stock_items__allocations__quantity',
filter=build_order_filter,
),
Decimal(0),
output_field=models.DecimalField(),
),
)
# Calculate 'available_stock' based on previously annotated fields
queryset = queryset.annotate(
available_stock=ExpressionWrapper(
F('total_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'),
output_field=models.DecimalField(),
)
)
# Extract similar information for any 'substitute' parts
queryset = queryset.alias(
substitute_stock=Coalesce(
SubquerySum(
'substitutes__part__stock_items__quantity',
filter=StockItem.IN_STOCK_FILTER,
),
Decimal(0),
output_field=models.DecimalField(),
),
substitute_build_allocations=Coalesce(
SubquerySum(
'substitutes__part__stock_items__allocations__quantity',
filter=build_order_filter,
),
Decimal(0),
output_field=models.DecimalField(),
),
substitute_sales_allocations=Coalesce(
SubquerySum(
'substitutes__part__stock_items__sales_order_allocations__quantity',
filter=sales_order_filter,
),
Decimal(0),
output_field=models.DecimalField(),
),
)
# Calculate 'available_substitute_stock' field
queryset = queryset.annotate(
available_substitute_stock=ExpressionWrapper(
F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'),
output_field=models.DecimalField(),
)
)
# Annotate the queryset with 'available variant stock' information
variant_stock_query = StockItem.objects.filter(
part__tree_id=OuterRef('sub_part__tree_id'),
part__lft__gt=OuterRef('sub_part__lft'),
part__rght__lt=OuterRef('sub_part__rght'),
).filter(StockItem.IN_STOCK_FILTER)
queryset = queryset.alias(
variant_stock_total=Coalesce(
Subquery(
variant_stock_query.annotate(
total=Func(F('quantity'), function='SUM', output_field=FloatField())
).values('total')),
0,
output_field=FloatField()
),
variant_stock_build_order_allocations=Coalesce(
Subquery(
variant_stock_query.annotate(
total=Func(F('sales_order_allocations__quantity'), function='SUM', output_field=FloatField()),
).values('total')),
0,
output_field=FloatField(),
),
variant_stock_sales_order_allocations=Coalesce(
Subquery(
variant_stock_query.annotate(
total=Func(F('allocations__quantity'), function='SUM', output_field=FloatField()),
).values('total')),
0,
output_field=FloatField(),
)
)
queryset = queryset.annotate(
available_variant_stock=ExpressionWrapper(
F('variant_stock_total') - F('variant_stock_build_order_allocations') - F('variant_stock_sales_order_allocations'),
output_field=FloatField(),
)
)
return queryset
def get_purchase_price_range(self, obj):
""" Return purchase price range """
@ -629,6 +872,12 @@ class BomItemSerializer(InvenTreeModelSerializer):
'substitutes',
'price_range',
'validated',
# Annotated fields describing available quantity
'available_stock',
'available_substitute_stock',
'available_variant_stock',
]

View File

@ -1,10 +0,0 @@
{% load i18n %}
<div class="markdownx row">
<div class="markdown col-md-6">
{% include 'django/forms/widgets/textarea.html' %}
</div>
<div class="markdown col-md-6">
<div class="markdownx-preview"></div>
</div>
</div>

View File

@ -187,6 +187,15 @@
</div>
</div>
<div class='panel panel-hidden' id='panel-stock'>
<div class='panel-heading'>
<h4>{% trans "Stock Items" %}</h4>
</div>
<div class='panel-content'>
{% include "stock_table.html" %}
</div>
</div>
<div class='panel panel-hidden' id='panel-parameters'>
<div class='panel-heading'>
<h4>{% trans "Part Parameters" %}</h4>
@ -223,6 +232,21 @@
{{ block.super }}
{% if category %}
onPanelLoad('stock', function() {
loadStockTable(
$('#stock-table'),
{
params: {
category: {{ category.pk }},
part_detail: true,
location_detail: true,
supplier_part_detail: true,
}
}
);
});
onPanelLoad('parameters', function() {
loadParametricPartTable(
"#parametric-part-table",

View File

@ -14,6 +14,8 @@
{% include "sidebar_link.html" with url=url text=text icon="fa-file-upload" %}
{% endif %}
{% if category %}
{% trans "Stock Items" as text %}
{% include "sidebar_item.html" with label='stock' text=text icon='fa-boxes' %}
{% trans "Parameters" as text %}
{% include "sidebar_item.html" with label="parameters" text=text icon="fa-tasks" %}
{% endif %}

View File

@ -3,7 +3,6 @@
{% load i18n %}
{% load inventree_extras %}
{% load crispy_forms_tags %}
{% load markdownify %}
{% block sidebar %}
{% include 'part/part_sidebar.html' %}
@ -125,8 +124,7 @@
</div>
</div>
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %}
{% if show_price_history %}
{% if part.purchaseable or part.salable %}
<div class='panel panel-hidden' id='panel-pricing'>
{% include "part/prices.html" %}
</div>
@ -134,24 +132,16 @@
<div class='panel panel-hidden' id='panel-part-notes'>
<div class='panel-heading'>
<div class='row'>
<div class='col-sm-6'>
<h4>{% trans "Notes" %}</h4>
</div>
<div class='col-sm-6'>
<div class='btn-group float-right'>
<button type='button' id='edit-notes' title='{% trans "Edit Notes" %}' class='btn btn-outline-secondary'>
<span class='fas fa-edit'>
</span>
</button>
</div>
<div class='d-flex flex-wrap'>
<h4>{% trans "Part Notes" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% include "notes_buttons.html" %}
</div>
</div>
</div>
<div class='panel-content'>
{% if part.notes %}
{{ part.notes | markdownify }}
{% endif %}
<textarea id='part-notes'></textarea>
</div>
</div>
@ -419,6 +409,18 @@
{% block js_ready %}
{{ block.super }}
// Load the "notes" tab
onPanelLoad('part-notes', function() {
setupNotesField(
'part-notes',
'{% url "api-part-detail" part.pk %}',
{
editable: {% if roles.part.change %}true{% else %}false{% endif %},
}
);
});
// Load the "scheduling" tab
onPanelLoad('scheduling', function() {
loadPartSchedulingChart('part-schedule-chart', {{ part.pk }});
@ -832,36 +834,6 @@
});
});
$('#edit-notes').click(function() {
constructForm('{% url "api-part-detail" part.pk %}', {
fields: {
notes: {
multiline: true,
}
},
title: '{% trans "Edit Part Notes" %}',
reload: true,
});
});
$(".slidey").change(function() {
var field = $(this).attr('fieldname');
var checked = $(this).prop('checked');
var data = {};
data[field] = checked;
// Update the particular field
inventreePut("{% url 'api-part-detail' part.id %}",
data,
{
method: 'PATCH',
reloadOnSuccess: true,
},
);
});
onPanelLoad("part-parameters", function() {
loadPartParameterTable(
'#parameter-table',
@ -1036,7 +1008,7 @@
pb_url_slug: 'internal-price',
pb_url: '{% url 'api-part-internal-price-list' %}',
pb_new_btn: $('#new-internal-price-break'),
pb_new_url: '{% url 'internal-price-break-create' %}',
pb_new_url: '{% url 'api-part-internal-price-list' %}',
linkedGraph: $('#InternalPriceBreakChart'),
},
);
@ -1052,7 +1024,7 @@
pb_url_slug: 'sale-price',
pb_url: "{% url 'api-part-sale-price-list' %}",
pb_new_btn: $('#new-price-break'),
pb_new_url: '{% url 'sale-price-break-create' %}',
pb_new_url: '{% url 'api-part-sale-price-list' %}',
linkedGraph: $('#SalePriceBreakChart'),
},
);

View File

@ -11,9 +11,8 @@
{% block content %}
{% trans "Import Parts from File" as header_text %}
{% roles.part.change as upload_go_ahead %}
{% trans "Unsuffitient privileges." as error_text %}
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=upload_go_ahead error_text=error_text %}
{% include "patterns/wizard/upload.html" with header_text=header_text upload_go_ahead=roles.part.change error_text=error_text %}
{% endblock %}
{% block js_ready %}

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 %}
@ -207,48 +211,21 @@
{% if part.component %}
{% if required_build_order_quantity > 0 %}
<tr>
<td><span class='fas fa-clipboard-list'></span></td>
<td>{% trans "Required for Build Orders" %}</td>
<td>{% decimal required_build_order_quantity %}</td>
</tr>
<tr>
<td><span class='fas fa-dolly'></span></td>
<td><span class='fas fa-tools'></span></td>
<td>{% trans "Allocated to Build Orders" %}</td>
<td>
{% decimal allocated_build_order_quantity %}
{% if allocated_build_order_quantity < required_build_order_quantity %}
<span class='fas fa-times-circle icon-red float-right' title='{% trans "Required quantity has not been allocated" %}'></span>
{% else %}
<span class='fas fa-check-circle icon-green float-right' title='{% trans "Required quantity has been allocated" %}'></span>
{% endif %}
</td>
<td>{% progress_bar allocated_build_order_quantity required_build_order_quantity id='build-order-allocated' max_width='150px' %}</td>
</tr>
{% endif %}
{% endif %}
{% if part.salable %}
{% if required_sales_order_quantity > 0 %}
<tr>
<td><span class='fas fa-clipboard-list'></span></td>
<td>{% trans "Required for Sales Orders" %}</td>
<td>
{% decimal required_sales_order_quantity %}
</td>
</tr>
<tr>
<td><span class='fas fa-dolly'></span></td>
<td><span class='fas fa-truck'></span></td>
<td>{% trans "Allocated to Sales Orders" %}</td>
<td>
{% decimal allocated_sales_order_quantity %}
{% if allocated_sales_order_quantity < required_sales_order_quantity %}
<span class='fas fa-times-circle icon-red float-right' title='{% trans "Required quantity has not been allocated" %}'></span>
{% else %}
<span class='fas fa-check-circle icon-green float-right' title='{% trans "Required quantity has been allocated" %}'></span>
{% endif %}
</td>
<td>{% progress_bar allocated_sales_order_quantity required_sales_order_quantity id='sales-order-allocated' max_width='150px' %}</td>
</tr>
{% endif %}
{% endif %}
{% if not part.is_template %}
{% if part.assembly %}
<tr>
<td><span class='fas fa-tools'></span></td>
@ -262,7 +239,6 @@
<td>{% decimal quantity_being_built %}</td>
</tr>
{% endif %}
{% endif %}
{% endif %}
</table>
{% endblock details_right %}
@ -424,9 +400,11 @@
);
});
{% if labels_enabled %}
$('#print-label').click(function() {
printPartLabels([{{ part.pk }}]);
});
{% endif %}
function adjustPartStock(action) {
inventreeGet(

View File

@ -4,7 +4,6 @@
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% settings_value 'PART_SHOW_RELATED' as show_related %}
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %}
{% trans "Parameters" as text %}
{% include "sidebar_item.html" with label="part-parameters" text=text icon="fa-th-list" %}
@ -28,7 +27,7 @@
{% trans "Used In" as text %}
{% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %}
{% endif %}
{% if show_price_history %}
{% if part.purchaseable or part.salable %}
{% trans "Pricing" as text %}
{% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %}
{% endif %}

View File

@ -3,6 +3,9 @@
{% load crispy_forms_tags %}
{% load inventree_extras %}
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% if show_price_history %}
<div class='panel-heading'>
<h4>{% trans "Pricing Information" %}</h4>
</div>
@ -43,7 +46,7 @@
{% endif %}
{% endif %}
{% if part.bom_count > 0 %}
{% if part.assembly and part.bom_count > 0 %}
{% if min_total_bom_price %}
<tr>
<td><strong>{% trans 'BOM Pricing' %}</strong>
@ -147,7 +150,7 @@
</div>
</div>
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% endif %}
{% if part.purchaseable and roles.purchase_order.view %}
<a class="anchor" id="supplier-cost"></a>
@ -170,7 +173,7 @@
</div>
</div>
{% if price_history %}
{% if show_price_history %}
<a class="anchor" id="purchase-price"></a>
<div class='panel-heading'>
<h4>{% trans "Purchase Price" %}
@ -279,6 +282,7 @@
</div>
</div>
{% if show_price_history %}
<a class="anchor" id="sale-price"></a>
<div class='panel-heading'>
<h4>{% trans "Sale Price" %}
@ -298,3 +302,5 @@
{% endif %}
</div>
{% endif %}
{% endif %}

View File

@ -18,7 +18,8 @@ from django.conf import settings as djangosettings
from django import template
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.templatetags.static import StaticNode
from django.templatetags.static import StaticNode, static
from django.core.files.storage import default_storage
from InvenTree import version, settings
@ -160,10 +161,11 @@ def inventree_in_debug_mode(*args, **kwargs):
@register.simple_tag()
def inventree_demo_mode(*args, **kwargs):
""" Return True if the server is running in DEMO mode """
return djangosettings.DEMO_MODE
def inventree_show_about(user, *args, **kwargs):
""" Return True if the about modal should be shown """
if InvenTreeSetting.get_setting('INVENTREE_RESTRICT_ABOUT') and not user.is_superuser:
return False
return True
@register.simple_tag()
@ -220,8 +222,13 @@ def python_version(*args, **kwargs):
@register.simple_tag()
def inventree_version(*args, **kwargs):
def inventree_version(shortstring=False, *args, **kwargs):
""" Return InvenTree version string """
if shortstring:
return _("{title} v{version}".format(
title=version.inventreeInstanceTitle(),
version=version.inventreeVersion()
))
return version.inventreeVersion()
@ -352,21 +359,24 @@ def visible_global_settings(*args, **kwargs):
@register.simple_tag()
def progress_bar(val, max, *args, **kwargs):
def progress_bar(val, max_val, *args, **kwargs):
"""
Render a progress bar element
"""
item_id = kwargs.get('id', 'progress-bar')
if val > max:
val = InvenTree.helpers.normalize(val)
max_val = InvenTree.helpers.normalize(max_val)
if val > max_val:
style = 'progress-bar-over'
elif val < max:
elif val < max_val:
style = 'progress-bar-under'
else:
style = ''
percent = float(val / max) * 100
percent = float(val / max_val) * 100
if percent > 100:
percent = 100
@ -383,7 +393,7 @@ def progress_bar(val, max, *args, **kwargs):
html = f"""
<div id='{item_id}' class='progress' style='{" ".join(style_tags)}'>
<div class='progress-bar {style}' role='progressbar' aria-valuemin='0' aria-valuemax='100' style='width:{percent}%'></div>
<div class='progress-value'>{val} / {max}</div>
<div class='progress-value'>{val} / {max_val}</div>
</div>
"""
@ -509,6 +519,22 @@ def mail_configured():
return bool(settings.EMAIL_HOST)
@register.simple_tag()
def inventree_customize(reference, *args, **kwargs):
""" Return customization values for the user interface """
return djangosettings.CUSTOMIZE.get(reference, '')
@register.simple_tag()
def inventree_logo(*args, **kwargs):
""" Return the path to the logo-file """
if settings.CUSTOM_LOGO:
return default_storage.url(settings.CUSTOM_LOGO)
return static('img/inventree.png')
class I18nStaticNode(StaticNode):
"""
custom StaticNode

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, PurchaseOrderStatus
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')
@ -564,6 +567,185 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(response.data['name'], name)
self.assertEqual(response.data['description'], description)
def test_template_filters(self):
"""
Unit tests for API filters related to template parts:
- variant_of : Return children of specified part
- ancestor : Return descendants of specified part
Uses the 'chair template' part (pk=10000)
"""
# Rebuild the MPTT structure before running these tests
Part.objects.rebuild()
url = reverse('api-part-list')
response = self.get(
url,
{
'variant_of': 10000,
},
expected_code=200
)
# 3 direct children of template part
self.assertEqual(len(response.data), 3)
response = self.get(
url,
{
'ancestor': 10000,
},
expected_code=200,
)
# 4 total descendants
self.assertEqual(len(response.data), 4)
# Use the 'green chair' as our reference
response = self.get(
url,
{
'variant_of': 10003,
},
expected_code=200,
)
self.assertEqual(len(response.data), 1)
response = self.get(
url,
{
'ancestor': 10003,
},
expected_code=200,
)
self.assertEqual(len(response.data), 1)
# Add some more variants
p = Part.objects.get(pk=10004)
for i in range(100):
Part.objects.create(
name=f'Chair variant {i}',
description='A new chair variant',
variant_of=p,
)
# There should still be only one direct variant
response = self.get(
url,
{
'variant_of': 10003,
},
expected_code=200,
)
self.assertEqual(len(response.data), 1)
# However, now should be 101 descendants
response = self.get(
url,
{
'ancestor': 10003,
},
expected_code=200,
)
self.assertEqual(len(response.data), 101)
def test_variant_stock(self):
"""
Unit tests for the 'variant_stock' annotation,
which provides a stock count for *variant* parts
"""
# Ensure the MPTT structure is in a known state before running tests
Part.objects.rebuild()
# Initially, there are no "chairs" in stock,
# so each 'chair' template should report variant_stock=0
url = reverse('api-part-list')
# Look at the "detail" URL for the master chair template
response = self.get('/api/part/10000/', {}, expected_code=200)
# This part should report 'zero' as variant stock
self.assertEqual(response.data['variant_stock'], 0)
# Grab a list of all variant chairs *under* the master template
response = self.get(
url,
{
'ancestor': 10000,
},
expected_code=200,
)
# 4 total descendants
self.assertEqual(len(response.data), 4)
for variant in response.data:
self.assertEqual(variant['variant_stock'], 0)
# Now, let's make some variant stock
for variant in Part.objects.get(pk=10000).get_descendants(include_self=False):
StockItem.objects.create(
part=variant,
quantity=100,
)
response = self.get('/api/part/10000/', {}, expected_code=200)
self.assertEqual(response.data['in_stock'], 0)
self.assertEqual(response.data['variant_stock'], 400)
# Check that each variant reports the correct stock quantities
response = self.get(
url,
{
'ancestor': 10000,
},
expected_code=200,
)
expected_variant_stock = {
10001: 0,
10002: 0,
10003: 100,
10004: 0,
}
for variant in response.data:
self.assertEqual(variant['in_stock'], 100)
self.assertEqual(variant['variant_stock'], expected_variant_stock[variant['pk']])
# Add some 'sub variants' for the green chair variant
green_chair = Part.objects.get(pk=10004)
for i in range(10):
gcv = Part.objects.create(
name=f"GC Var {i}",
description="Green chair variant",
variant_of=green_chair,
)
StockItem.objects.create(
part=gcv,
quantity=50,
)
# Spot check of some values
response = self.get('/api/part/10000/', {})
self.assertEqual(response.data['variant_stock'], 900)
response = self.get('/api/part/10004/', {})
self.assertEqual(response.data['variant_stock'], 500)
class PartDetailTests(InvenTreeAPITestCase):
"""
@ -575,7 +757,12 @@ class PartDetailTests(InvenTreeAPITestCase):
'part',
'location',
'bom',
'company',
'test_templates',
'manufacturer_part',
'supplier_part',
'order',
'stock',
]
roles = [
@ -802,6 +989,38 @@ class PartDetailTests(InvenTreeAPITestCase):
# And now check that the image has been set
p = Part.objects.get(pk=pk)
def test_details(self):
"""
Test that the required details are available
"""
p = Part.objects.get(pk=1)
url = reverse('api-part-detail', kwargs={'pk': 1})
data = self.get(url, expected_code=200).data
# How many parts are 'on order' for this part?
lines = order.models.PurchaseOrderLineItem.objects.filter(
part__part__pk=1,
order__status__in=PurchaseOrderStatus.OPEN,
)
on_order = 0
# Calculate the "on_order" quantity by hand,
# to check it matches the API value
for line in lines:
on_order += line.quantity
on_order -= line.received
self.assertEqual(on_order, data['ordering'])
self.assertEqual(on_order, p.on_order)
# Some other checks
self.assertEqual(data['in_stock'], 9000)
self.assertEqual(data['unallocated_stock'], 9000)
class PartAPIAggregationTest(InvenTreeAPITestCase):
"""
@ -815,6 +1034,10 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
'location',
'bom',
'test_templates',
'build',
'location',
'stock',
'sales_order',
]
roles = [
@ -826,6 +1049,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 +1106,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):
"""
@ -966,6 +1339,12 @@ class BomItemTest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['pk'], bom_item.pk)
# Each item in response should contain expected keys
for el in response.data:
for key in ['available_stock', 'available_substitute_stock']:
self.assertTrue(key in el)
def test_get_bom_detail(self):
"""
Get the detail view for a single BomItem object
@ -975,6 +1354,26 @@ class BomItemTest(InvenTreeAPITestCase):
response = self.get(url, expected_code=200)
expected_values = [
'allow_variants',
'inherited',
'note',
'optional',
'overage',
'pk',
'part',
'quantity',
'reference',
'sub_part',
'substitutes',
'validated',
'available_stock',
'available_substitute_stock',
]
for key in expected_values:
self.assertTrue(key in response.data)
self.assertEqual(int(float(response.data['quantity'])), 25)
# Increase the quantity
@ -1162,6 +1561,21 @@ class BomItemTest(InvenTreeAPITestCase):
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 5)
# The BomItem detail endpoint should now also reflect the substitute data
data = self.get(
reverse('api-bom-item-detail', kwargs={'pk': bom_item.pk}),
expected_code=200
).data
# 5 substitute parts
self.assertEqual(len(data['substitutes']), 5)
# 5 x 1,000 stock quantity
self.assertEqual(data['available_substitute_stock'], 5000)
# 9,000 stock directly available
self.assertEqual(data['available_stock'], 9000)
def test_bom_item_uses(self):
"""
Tests for the 'uses' field
@ -1215,6 +1629,44 @@ class BomItemTest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), i)
def test_bom_variant_stock(self):
"""
Test for 'available_variant_stock' annotation
"""
Part.objects.rebuild()
# BOM item we are interested in
bom_item = BomItem.objects.get(pk=1)
response = self.get('/api/bom/1/', {}, expected_code=200)
# Initially, no variant stock available
self.assertEqual(response.data['available_variant_stock'], 0)
# Create some 'variants' of the referenced sub_part
bom_item.sub_part.is_template = True
bom_item.sub_part.save()
for i in range(10):
# Create a variant part
vp = Part.objects.create(
name=f"Var {i}",
description="Variant part",
variant_of=bom_item.sub_part,
)
# Create a stock item
StockItem.objects.create(
part=vp,
quantity=100,
)
# There should now be variant stock available
response = self.get('/api/bom/1/', {}, expected_code=200)
self.assertEqual(response.data['available_variant_stock'], 1000)
class PartParameterTest(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

@ -349,6 +349,26 @@ class PartSettingsTest(TestCase):
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C')
part.full_clean()
# Any duplicate IPN should raise an error
Part.objects.create(name='xyz', revision='1', description='A part', IPN='UNIQUE')
# Case insensitive, so variations on spelling should throw an error
for ipn in ['UNiquE', 'uniQuE', 'unique']:
with self.assertRaises(ValidationError):
Part.objects.create(name='xyz', revision='2', description='A part', IPN=ipn)
with self.assertRaises(ValidationError):
Part.objects.create(name='zyx', description='A part', IPN='UNIQUE')
# However, *blank* / empty IPN values should be allowed, even if duplicates are not
# Note that leading / trailling whitespace characters are trimmed, too
Part.objects.create(name='abc', revision='1', description='A part', IPN=None)
Part.objects.create(name='abc', revision='2', description='A part', IPN='')
Part.objects.create(name='abc', revision='3', description='A part', IPN=None)
Part.objects.create(name='abc', revision='4', description='A part', IPN=' ')
Part.objects.create(name='abc', revision='5', description='A part', IPN=' ')
Part.objects.create(name='abc', revision='6', description='A part', IPN=' ')
class PartSubscriptionTests(TestCase):

View File

@ -13,18 +13,6 @@ from django.conf.urls import url, include
from . import views
sale_price_break_urls = [
url(r'^new/', views.PartSalePriceBreakCreate.as_view(), name='sale-price-break-create'),
url(r'^(?P<pk>\d+)/edit/', views.PartSalePriceBreakEdit.as_view(), name='sale-price-break-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PartSalePriceBreakDelete.as_view(), name='sale-price-break-delete'),
]
internal_price_break_urls = [
url(r'^new/', views.PartInternalPriceBreakCreate.as_view(), name='internal-price-break-create'),
url(r'^(?P<pk>\d+)/edit/', views.PartInternalPriceBreakEdit.as_view(), name='internal-price-break-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PartInternalPriceBreakDelete.as_view(), name='internal-price-break-delete'),
]
part_parameter_urls = [
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
@ -86,12 +74,6 @@ part_urls = [
# Part category
url(r'^category/', include(category_urls)),
# Part price breaks
url(r'^sale-price/', include(sale_price_break_urls)),
# Part internal price breaks
url(r'^internal-price/', include(internal_price_break_urls)),
# Part parameters
url(r'^parameter/', include(part_parameter_urls)),

View File

@ -18,7 +18,6 @@ from django.forms import HiddenInput
from django.conf import settings
from django.contrib import messages
from moneyed import CURRENCIES
from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate
@ -33,7 +32,6 @@ from decimal import Decimal
from .models import PartCategory, Part
from .models import PartParameterTemplate
from .models import PartCategoryParameterTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak
from common.models import InvenTreeSetting
from company.models import SupplierPart
@ -389,8 +387,12 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
context.update(**ctx)
show_price_history = InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False)
context['show_price_history'] = show_price_history
# Pricing information
if InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False):
if show_price_history:
ctx = self.get_pricing(self.get_quantity())
ctx['form'] = self.form_class(initial=self.get_initials())
@ -1226,102 +1228,3 @@ class CategoryParameterTemplateDelete(AjaxDeleteView):
return None
return self.object
class PartSalePriceBreakCreate(AjaxCreateView):
"""
View for creating a sale price break for a part
"""
model = PartSellPriceBreak
form_class = part_forms.EditPartSalePriceBreakForm
ajax_form_title = _('Add Price Break')
def get_data(self):
return {
'success': _('Added new price break')
}
def get_part(self):
try:
part = Part.objects.get(id=self.request.GET.get('part'))
except (ValueError, Part.DoesNotExist):
part = None
if part is None:
try:
part = Part.objects.get(id=self.request.POST.get('part'))
except (ValueError, Part.DoesNotExist):
part = None
return part
def get_form(self):
form = super(AjaxCreateView, self).get_form()
form.fields['part'].widget = HiddenInput()
return form
def get_initial(self):
initials = super(AjaxCreateView, self).get_initial()
initials['part'] = self.get_part()
default_currency = inventree_settings.currency_code_default()
currency = CURRENCIES.get(default_currency, None)
if currency is not None:
initials['price'] = [1.0, currency]
return initials
class PartSalePriceBreakEdit(AjaxUpdateView):
""" View for editing a sale price break """
model = PartSellPriceBreak
form_class = part_forms.EditPartSalePriceBreakForm
ajax_form_title = _('Edit Price Break')
def get_form(self):
form = super().get_form()
form.fields['part'].widget = HiddenInput()
return form
class PartSalePriceBreakDelete(AjaxDeleteView):
""" View for deleting a sale price break """
model = PartSellPriceBreak
ajax_form_title = _("Delete Price Break")
ajax_template_name = "modal_delete_form.html"
class PartInternalPriceBreakCreate(PartSalePriceBreakCreate):
""" View for creating a internal price break for a part """
model = PartInternalPriceBreak
form_class = part_forms.EditPartInternalPriceBreakForm
ajax_form_title = _('Add Internal Price Break')
permission_required = 'roles.sales_order.add'
class PartInternalPriceBreakEdit(PartSalePriceBreakEdit):
""" View for editing a internal price break """
model = PartInternalPriceBreak
form_class = part_forms.EditPartInternalPriceBreakForm
ajax_form_title = _('Edit Internal Price Break')
permission_required = 'roles.sales_order.change'
class PartInternalPriceBreakDelete(PartSalePriceBreakDelete):
""" View for deleting a internal price break """
model = PartInternalPriceBreak
ajax_form_title = _("Delete Internal Price Break")
permission_required = 'roles.sales_order.delete'

View File

@ -504,10 +504,10 @@ class APICallMixin:
@property
def api_headers(self):
return {
self.API_TOKEN: self.get_setting(self.API_TOKEN_SETTING),
'Content-Type': 'application/json'
}
headers = {'Content-Type': 'application/json'}
if getattr(self, 'API_TOKEN_SETTING'):
headers[self.API_TOKEN] = self.get_setting(self.API_TOKEN_SETTING)
return headers
def api_build_url_args(self, arguments):
groups = []
@ -515,16 +515,21 @@ class APICallMixin:
groups.append(f'{key}={",".join([str(a) for a in val])}')
return f'?{"&".join(groups)}'
def api_call(self, endpoint, method: str = 'GET', url_args=None, data=None, headers=None, simple_response: bool = True):
def api_call(self, endpoint, method: str = 'GET', url_args=None, data=None, headers=None, simple_response: bool = True, endpoint_is_url: bool = False):
if url_args:
endpoint += self.api_build_url_args(url_args)
if headers is None:
headers = self.api_headers
if endpoint_is_url:
url = endpoint
else:
url = f'{self.api_url}/{endpoint}'
# build kwargs for call
kwargs = {
'url': f'{self.api_url}/{endpoint}',
'url': url,
'headers': headers,
}
if data:

View File

@ -94,6 +94,14 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
"""
return getattr(self, 'is_package', False)
@property
def is_sample(self):
"""
Is this plugin part of the samples?
"""
path = str(self.package_path)
return path.startswith('plugin/samples/')
# region properties
@property
def slug(self):

View File

@ -4,7 +4,6 @@
{% load report %}
{% load barcode %}
{% load inventree_extras %}
{% load markdownify %}
{% block page_margin %}
margin: 2cm;
@ -16,7 +15,7 @@ content: "v{{report_revision}} - {{ date.isoformat }}";
{% endblock %}
{% block bottom_center %}
content: "InvenTree v{% inventree_version %}";
content: "{% inventree_version shortstring=True %}";
{% endblock %}
{% block style %}

View File

@ -16,7 +16,7 @@ content: "v{{report_revision}} - {{ date.isoformat }}";
{% endblock %}
{% block bottom_center %}
content: "InvenTree v{% inventree_version %}";
content: "{% inventree_version shortstring=True %}";
{% endblock %}
{% block style %}

View File

@ -14,7 +14,7 @@ content: "{{ date.isoformat }}";
{% endblock %}
{% block bottom_center %}
content: "InvenTree v{% inventree_version %}";
content: "{% inventree_version shortstring=True %}";
{% endblock %}
{% block top_center %}

View File

@ -1,116 +0,0 @@
"""
This script is used to simplify the translation process.
Django provides a framework for working out which strings are "translatable",
and these strings are then dumped in a file under InvenTree/locale/<lang>/LC_MESSAGES/django.po
This script presents the translator with a list of strings which have not yet been translated,
allowing for a simpler and quicker translation process.
If a string translation needs to be updated, this will still need to be done manually,
by editing the appropriate .po file.
"""
import argparse
import os
import sys
def manually_translate_file(filename, save=False):
"""
Manually translate a .po file.
Present any missing translation strings to the translator,
and write their responses back to the file.
"""
print("Add manual translations to '{f}'".format(f=filename))
print("For each missing translation:")
print("a) Directly enter a new tranlation in the target language")
print("b) Leave empty to skip")
print("c) Press Ctrl+C to exit")
print("-------------------------")
input("Press <ENTER> to start")
print("")
with open(filename, 'r') as f:
lines = f.readlines()
out = []
# Context data
source_line = ''
msgid = ''
for num, line in enumerate(lines):
# Keep track of context data BEFORE an empty msgstr object
line = line.strip()
if line.startswith("#: "):
source_line = line.replace("#: ", "")
elif line.startswith("msgid "):
msgid = line.replace("msgid ", "")
if line.strip() == 'msgstr ""':
# We have found an empty translation!
if msgid and len(msgid) > 0 and not msgid == '""':
print("Source:", source_line)
print("Enter translation for {t}".format(t=msgid))
try:
translation = str(input(">"))
except KeyboardInterrupt:
break
if translation and len(translation) > 0:
# Update the line with the new translation
line = 'msgstr "{msg}"'.format(msg=translation)
out.append(line + "\r\n")
if save:
with open(filename, 'w') as output_file:
output_file.writelines(out)
print("Translation done: written to", filename)
print("Run 'invoke translate' to rebuild translation data")
if __name__ == '__main__':
MY_DIR = os.path.dirname(os.path.realpath(__file__))
LOCALE_DIR = os.path.join(MY_DIR, '..', 'locale')
if not os.path.exists(LOCALE_DIR):
print("Error: {d} does not exist!".format(d=LOCALE_DIR))
sys.exit(1)
parser = argparse.ArgumentParser(description="InvenTree Translation Helper")
parser.add_argument('language', help='Language code', action='store')
parser.add_argument('--fake', help="Do not save updated translations", action='store_true')
args = parser.parse_args()
language = args.language
LANGUAGE_DIR = os.path.abspath(os.path.join(LOCALE_DIR, language))
# Check that a locale directory exists for the given language!
if not os.path.exists(LANGUAGE_DIR):
print("Error: Locale directory for language '{l}' does not exist".format(l=language))
sys.exit(1)
# Check that a .po file exists for the given language!
PO_FILE = os.path.join(LANGUAGE_DIR, 'LC_MESSAGES', 'django.po')
if not os.path.exists(PO_FILE):
print("Error: File '{f}' does not exist".format(f=PO_FILE))
sys.exit(1)
# Ok, now we run the user through the translation file
manually_translate_file(PO_FILE, save=args.fake is not True)

View File

@ -402,11 +402,51 @@ class StockFilter(rest_filters.FilterSet):
serialized = rest_filters.BooleanFilter(label='Has serial number', method='filter_serialized')
def filter_serialized(self, queryset, name, value):
"""
Filter by whether the StockItem has a serial number (or not)
"""
q = Q(serial=None) | Q(serial='')
if str2bool(value):
queryset = queryset.exclude(serial=None)
queryset = queryset.exclude(q)
else:
queryset = queryset.filter(serial=None)
queryset = queryset.filter(q)
return queryset
has_batch = rest_filters.BooleanFilter(label='Has batch code', method='filter_has_batch')
def filter_has_batch(self, queryset, name, value):
"""
Filter by whether the StockItem has a batch code (or not)
"""
q = Q(batch=None) | Q(batch='')
if str2bool(value):
queryset = queryset.exclude(q)
else:
queryset = queryset.filter(q)
return queryset
tracked = rest_filters.BooleanFilter(label='Tracked', method='filter_tracked')
def filter_tracked(self, queryset, name, value):
"""
Filter by whether this stock item is *tracked*, meaning either:
- It has a serial number
- It has a batch code
"""
q_batch = Q(batch=None) | Q(batch='')
q_serial = Q(serial=None) | Q(serial='')
if str2bool(value):
queryset = queryset.exclude(q_batch & q_serial)
else:
queryset = queryset.filter(q_batch & q_serial)
return queryset
@ -1105,7 +1145,6 @@ class StockItemTestResultList(generics.ListCreateAPIView):
]
filter_fields = [
'stock_item',
'test',
'user',
'result',
@ -1114,6 +1153,38 @@ class StockItemTestResultList(generics.ListCreateAPIView):
ordering = 'date'
def filter_queryset(self, queryset):
params = self.request.query_params
queryset = super().filter_queryset(queryset)
# Filter by stock item
item = params.get('stock_item', None)
if item is not None:
try:
item = StockItem.objects.get(pk=item)
items = [item]
# Do we wish to also include test results for 'installed' items?
include_installed = str2bool(params.get('include_installed', False))
if include_installed:
# Include items which are installed "underneath" this item
# Note that this function is recursive!
installed_items = item.get_installed_items(cascade=True)
items += [it for it in installed_items]
queryset = queryset.filter(stock_item__in=items)
except (ValueError, StockItem.DoesNotExist):
pass
return queryset
def get_serializer(self, *args, **kwargs):
try:
kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False))
@ -1189,6 +1260,15 @@ class StockTrackingList(generics.ListAPIView):
if not deltas:
deltas = {}
# Add part detail
if 'part' in deltas:
try:
part = Part.objects.get(pk=deltas['part'])
serializer = PartBriefSerializer(part)
deltas['part_detail'] = serializer.data
except:
pass
# Add location detail
if 'location' in deltas:
try:

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

@ -0,0 +1,19 @@
# Generated by Django 3.2.12 on 2022-04-26 10:19
from django.db import migrations, models
import stock.models
class Migration(migrations.Migration):
dependencies = [
('stock', '0073_alter_stockitem_belongs_to'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='batch',
field=models.CharField(blank=True, default=stock.models.generate_batch_code, help_text='Batch code for this stock item', max_length=100, null=True, verbose_name='Batch Code'),
),
]

View File

@ -8,6 +8,8 @@ from __future__ import unicode_literals
import os
from jinja2 import Template
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError, FieldError
from django.urls import reverse
@ -213,6 +215,32 @@ class StockItemManager(TreeManager):
)
def generate_batch_code():
"""
Generate a default 'batch code' for a new StockItem.
This uses the value of the 'STOCK_BATCH_CODE_TEMPLATE' setting (if configured),
which can be passed through a simple template.
"""
batch_template = common.models.InvenTreeSetting.get_setting('STOCK_BATCH_CODE_TEMPLATE', '')
now = datetime.now()
# Pass context data through to the template randering.
# The folowing context variables are availble for custom batch code generation
context = {
'date': now,
'year': now.year,
'month': now.month,
'day': now.day,
'hour': now.minute,
'minute': now.minute,
}
return Template(batch_template).render(context)
class StockItem(MPTTModel):
"""
A StockItem object represents a quantity of physical instances of a part.
@ -453,6 +481,14 @@ class StockItem(MPTTModel):
super().clean()
# Strip serial number field
if type(self.serial) is str:
self.serial = self.serial.strip()
# Strip batch code field
if type(self.batch) is str:
self.batch = self.batch.strip()
try:
if self.part.trackable:
# Trackable parts must have integer values for quantity field!
@ -636,7 +672,8 @@ class StockItem(MPTTModel):
batch = models.CharField(
verbose_name=_('Batch Code'),
max_length=100, blank=True, null=True,
help_text=_('Batch code for this stock item')
help_text=_('Batch code for this stock item'),
default=generate_batch_code,
)
quantity = models.DecimalField(
@ -718,6 +755,33 @@ class StockItem(MPTTModel):
help_text=_('Select Owner'),
related_name='stock_items')
@transaction.atomic
def convert_to_variant(self, variant, user, notes=None):
"""
Convert this StockItem instance to a "variant",
i.e. change the "part" reference field
"""
if not variant:
# Ignore null values
return
if variant == self.part:
# Variant is the same as the current part
return
self.part = variant
self.save()
self.add_tracking_entry(
StockHistoryCode.CONVERTED_TO_VARIANT,
user,
deltas={
'part': variant.pk,
},
notes=_('Converted to part') + ': ' + variant.full_name,
)
def get_item_owner(self):
"""
Return the closest "owner" for this StockItem.

View File

@ -4,7 +4,6 @@
{% load inventree_extras %}
{% load i18n %}
{% load l10n %}
{% load markdownify %}
{% block sidebar %}
{% include "stock/stock_sidebar.html" %}
@ -27,11 +26,12 @@
</div>
</div>
<div class='panel-content'>
<div id='table-toolbar'>
<div id='tracking-table-toolbar'>
<div class='btn-group'>
{% include "filter_list.html" with id="stocktracking" %}
</div>
</div>
<table class='table table-condensed table-striped' id='track-table' data-toolbar='#table-toolbar'>
<table class='table table-condensed table-striped' id='track-table' data-toolbar='#tracking-table-toolbar'>
</table>
</div>
</div>
@ -133,24 +133,16 @@
<div class='panel panel-hidden' id='panel-notes'>
<div class='panel-heading'>
<div class='row'>
<div class='col-sm-6'>
<h4>{% trans "Stock Item Notes" %}</h4>
</div>
<div class='col-sm-6'>
<div class='btn-group float-right'>
<button type='button' id='edit-notes' title='{% trans "Edit Notes" %}' class='btn btn-small btn-outline-secondary'>
<span class='fas fa-edit'>
</span>
</button>
</div>
<div class='d-flex flex-wrap'>
<h4>{% trans "Stock Item Notes" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% include "notes_buttons.html" %}
</div>
</div>
</div>
<div class='panel-content'>
{% if item.notes %}
{{ item.notes | markdownify }}
{% endif %}
<textarea id='stock-notes'></textarea>
</div>
</div>
@ -235,18 +227,21 @@
reload: true,
}
);
});
$('#edit-notes').click(function() {
constructForm('{% url "api-stock-detail" item.pk %}', {
fields: {
notes: {
multiline: true,
}
},
title: '{% trans "Edit Notes" %}',
reload: true,
});
});
onPanelLoad('notes', function() {
setupNotesField(
'stock-notes',
'{% url "api-stock-detail" item.pk %}',
{
{% if roles.stock.change and user_owns_item %}
editable: true,
{% else %}
editable: false,
{% endif %}
}
);
});
enableDragAndDrop(
@ -348,7 +343,6 @@
);
});
loadStockTrackingTable($("#track-table"), {
params: {
ordering: '-date',

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 %}
@ -500,7 +505,12 @@ $("#barcode-unlink").click(function() {
});
$("#barcode-scan-into-location").click(function() {
scanItemsIntoLocation([{{ item.id }}]);
inventreeGet('{% url "api-stock-detail" item.pk %}', {}, {
success: function(item) {
scanItemsIntoLocation([item]);
}
});
});
function itemAdjust(action) {

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,11 +205,51 @@ 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'])
def test_filter_by_has_batch(self):
"""
Test the 'has_batch' filter, which tests if the stock item has been assigned a batch code
"""
with_batch = self.get_stock(has_batch=1)
without_batch = self.get_stock(has_batch=0)
n_stock_items = StockItem.objects.all().count()
# Total sum should equal the total count of stock items
self.assertEqual(n_stock_items, len(with_batch) + len(without_batch))
for item in with_batch:
self.assertFalse(item['batch'] in [None, ''])
for item in without_batch:
self.assertTrue(item['batch'] in [None, ''])
def test_filter_by_tracked(self):
"""
Test the 'tracked' filter.
This checks if the stock item has either a batch code *or* a serial number
"""
tracked = self.get_stock(tracked=True)
untracked = self.get_stock(tracked=False)
n_stock_items = StockItem.objects.all().count()
self.assertEqual(n_stock_items, len(tracked) + len(untracked))
blank = [None, '']
for item in tracked:
self.assertTrue(item['batch'] not in blank or item['serial'] not in blank)
for item in untracked:
self.assertTrue(item['batch'] in blank and item['serial'] in blank)
def test_filter_by_expired(self):
"""
Filter StockItem by expiry status
@ -217,7 +257,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 +272,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 +289,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 +330,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 +349,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

@ -644,6 +644,16 @@ class StockItemConvert(AjaxUpdateView):
return form
def save(self, obj, form):
stock_item = self.get_object()
variant = form.cleaned_data.get('part', None)
stock_item.convert_to_variant(variant, user=self.request.user)
return stock_item
class StockLocationCreate(AjaxCreateView):
"""

View File

@ -1,8 +1,9 @@
{% extends "base.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block page_title %}
InvenTree | {% trans "Permission Denied" %}
{% inventree_title %} | {% trans "Permission Denied" %}
{% endblock %}
{% block content %}

View File

@ -1,8 +1,9 @@
{% extends "base.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block page_title %}
InvenTree | {% trans "Page Not Found" %}
{% inventree_title %} | {% trans "Page Not Found" %}
{% endblock %}
{% block content %}

View File

@ -1,8 +1,9 @@
{% extends "base.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block page_title %}
InvenTree | {% trans "Internal Server Error" %}
{% inventree_title %} | {% trans "Internal Server Error" %}
{% endblock %}
{% block content %}
@ -11,7 +12,7 @@ InvenTree | {% trans "Internal Server Error" %}
<h3>{% trans "Internal Server Error" %}</h3>
<div class='alert alert-danger alert-block'>
{% trans "The InvenTree server raised an internal error" %}<br>
{% blocktrans %}The {{ inventree_title }} server raised an internal error{% endblocktrans %}<br>
{% trans "Refer to the error log in the admin interface for further details" %}
</div>
</div>

View File

@ -30,7 +30,7 @@
<div class='container-fluid'>
<div class='clearfix content-heading login-header d-flex flex-wrap'>
<img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/>
<img class="pull-left" src="{% inventree_logo %}" width="60" height="60"/>
{% include "spacer.html" %}
<span class='float-right'><h3>{% block body_title %}{% trans 'Site is in Maintenance' %}{% endblock %}</h3></span>
</div>

View File

@ -15,6 +15,7 @@
<tbody>
{% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE_TITLE" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_RESTRICT_ABOUT" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_BASE_URL" icon="fa-globe" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_COMPANY_NAME" icon="fa-building" %}
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_FROM_URL" icon="fa-cloud-download-alt" %}

View File

@ -13,7 +13,7 @@
{% block content %}
<div class='alert alert-block alert-danger'>
{% trans "Changing the settings below require you to immediatly restart InvenTree. Do not change this while under active usage." %}
{% trans "Changing the settings below require you to immediatly restart the server. Do not change this while under active usage." %}
</div>
<div class='table-responsive'>
@ -24,6 +24,7 @@
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" icon="fa-link" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" icon="fa-sitemap" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %}
{% include "InvenTree/settings/setting.html" with key="PLUGIN_ON_STARTUP" %}
</tbody>
</table>
</div>
@ -76,6 +77,12 @@
{% endfor %}
{% endif %}
{% if plugin.is_sample %}
<a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar">
<span class='badge bg-info rounded-pill'>{% trans "code sample" %}</span>
</a>
{% endif %}
{% if plugin.website %}
<a href="{{ plugin.website }}"><span class="fas fa-globe"></span></a>
{% endif %}

View File

@ -85,7 +85,7 @@
{% if plugin.is_package %}
{% trans "This plugin was installed as a package" %}
{% else %}
{% trans "This plugin was found in a local InvenTree path" %}
{% trans "This plugin was found in a local server path" %}
{% endif %}
</td>
</tr>

View File

@ -89,7 +89,7 @@ $('table').find('.boolean-setting').change(function() {
},
{
method: 'PATCH',
onSuccess: function(data) {
success: function(data) {
},
error: function(xhr) {
showApiError(xhr, url);

View File

@ -11,6 +11,7 @@
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="STOCK_BATCH_CODE_TEMPLATE" icon="fa-layer-group" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %}

View File

@ -13,15 +13,15 @@
{% endblock %}
{% block actions %}
{% inventree_demo_mode as demo %}
{% if not demo %}
{% inventree_customize 'hide_password_reset' as hide_password_reset %}
{% if not hide_password_reset %}
<div class='btn btn-outline-primary' type='button' id='edit-password' title='{% trans "Change Password" %}'>
<span class='fas fa-key'></span> {% trans "Set Password" %}
</div>
{% endif %}
<div class='btn btn-primary' type='button' id='edit-user' title='{% trans "Edit User Information" %}'>
<span class='fas fa-user-cog'></span> {% trans "Edit" %}
</div>
{% endif %}
{% endblock %}
{% block content %}

Some files were not shown because too many files have changed in this diff Show More