Merge remote-tracking branch 'inventree/master' into order-parts-wizard

# Conflicts:
#	InvenTree/templates/js/translated/model_renderers.js
This commit is contained in:
Oliver Walters 2022-04-26 21:22:31 +10:00
commit b8ca7fb092
94 changed files with 39337 additions and 12250 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

@ -1,11 +1,8 @@
from django.shortcuts import HttpResponseRedirect from django.shortcuts import HttpResponseRedirect
from django.urls import reverse_lazy, Resolver404 from django.urls import reverse_lazy, Resolver404
from django.db import connection
from django.shortcuts import redirect from django.shortcuts import redirect
from django.conf.urls import include, url from django.conf.urls import include, url
import logging import logging
import time
import operator
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from allauth_2fa.middleware import BaseRequire2FAMiddleware, AllauthTwoFactorMiddleware from allauth_2fa.middleware import BaseRequire2FAMiddleware, AllauthTwoFactorMiddleware
@ -92,67 +89,6 @@ class AuthRequiredMiddleware(object):
return response 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)) url_matcher = url('', include(frontendpatterns))

View File

@ -282,6 +282,7 @@ INSTALLED_APPS = [
MIDDLEWARE = CONFIG.get('middleware', [ MIDDLEWARE = CONFIG.get('middleware', [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'x_forwarded_for.middleware.XForwardedForMiddleware',
'user_sessions.middleware.SessionMiddleware', # db user sessions 'user_sessions.middleware.SessionMiddleware', # db user sessions
'django.middleware.locale.LocaleMiddleware', 'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
@ -545,11 +546,19 @@ if "sqlite" in db_engine:
# Provide OPTIONS dict back to the database configuration dict # Provide OPTIONS dict back to the database configuration dict
db_config['OPTIONS'] = db_options 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 = { DATABASES = {
'default': db_config 'default': db_config
} }
_cache_config = CONFIG.get("cache", {}) _cache_config = CONFIG.get("cache", {})
_cache_host = _cache_config.get("host", os.getenv("INVENTREE_CACHE_HOST")) _cache_host = _cache_config.get("host", os.getenv("INVENTREE_CACHE_HOST"))
_cache_port = _cache_config.get( _cache_port = _cache_config.get(
@ -662,11 +671,13 @@ LANGUAGE_CODE = CONFIG.get('language', 'en-us')
# If a new language translation is supported, it must be added here # If a new language translation is supported, it must be added here
LANGUAGES = [ LANGUAGES = [
('cs', _('Czech')),
('de', _('German')), ('de', _('German')),
('el', _('Greek')), ('el', _('Greek')),
('en', _('English')), ('en', _('English')),
('es', _('Spanish')), ('es', _('Spanish')),
('es-mx', _('Spanish (Mexican)')), ('es-mx', _('Spanish (Mexican)')),
('fa', _('Farsi / Persian')),
('fr', _('French')), ('fr', _('French')),
('he', _('Hebrew')), ('he', _('Hebrew')),
('hu', _('Hungarian')), ('hu', _('Hungarian')),
@ -676,7 +687,8 @@ LANGUAGES = [
('nl', _('Dutch')), ('nl', _('Dutch')),
('no', _('Norwegian')), ('no', _('Norwegian')),
('pl', _('Polish')), ('pl', _('Polish')),
('pt', _('Portugese')), ('pt', _('Portuguese')),
('pt-BR', _('Portuguese (Brazilian)')),
('ru', _('Russian')), ('ru', _('Russian')),
('sv', _('Swedish')), ('sv', _('Swedish')),
('th', _('Thai')), ('th', _('Thai')),

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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 * 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 * 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 */ * Copyright jQuery Foundation and other contributors; Licensed MIT */
@ -17,11 +17,11 @@
$.ui = $.ui || {}; $.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 * http://jqueryui.com
* *
* Copyright jQuery Foundation and other contributors * 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 * http://jqueryui.com
* *
* Copyright jQuery Foundation and other contributors * 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 * http://jqueryui.com
* *
* Copyright jQuery Foundation and other contributors * 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 * http://jqueryui.com
* *
* Copyright jQuery Foundation and other contributors * 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 * http://jqueryui.com
* *
* Copyright jQuery Foundation and other contributors * Copyright jQuery Foundation and other contributors
@ -1347,7 +1347,7 @@ var uniqueId = $.fn.extend( {
var ie = $.ui.ie = !!/msie [\w.]+/.exec( navigator.userAgent.toLowerCase() ); 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 * http://jqueryui.com
* *
* Copyright jQuery Foundation and other contributors * Copyright jQuery Foundation and other contributors
@ -1368,7 +1368,7 @@ $( document ).on( "mouseup", function() {
} ); } );
var widgetsMouse = $.widget( "ui.mouse", { var widgetsMouse = $.widget( "ui.mouse", {
version: "1.12.1", version: "1.13.0",
options: { options: {
cancel: "input, textarea, button, select, option", cancel: "input, textarea, button, select, option",
distance: 1, 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 * http://jqueryui.com
* *
* Copyright jQuery Foundation and other contributors * Copyright jQuery Foundation and other contributors
@ -1612,7 +1612,7 @@ var plugin = $.ui.plugin = {
$.widget( "ui.resizable", $.ui.mouse, { $.widget( "ui.resizable", $.ui.mouse, {
version: "1.12.1", version: "1.13.0",
widgetEventPrefix: "resize", widgetEventPrefix: "resize",
options: { options: {
alsoResize: false, 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 * http://jqueryui.com
* *
* Copyright jQuery Foundation and other contributors * Copyright jQuery Foundation and other contributors
@ -2826,7 +2826,7 @@ var safeActiveElement = $.ui.safeActiveElement = function( document ) {
var widgetsMenu = $.widget( "ui.menu", { var widgetsMenu = $.widget( "ui.menu", {
version: "1.12.1", version: "1.13.0",
defaultElement: "<ul>", defaultElement: "<ul>",
delay: 300, delay: 300,
options: { 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 * http://jqueryui.com
* *
* Copyright jQuery Foundation and other contributors * Copyright jQuery Foundation and other contributors
@ -3481,7 +3481,7 @@ var widgetsMenu = $.widget( "ui.menu", {
$.widget( "ui.autocomplete", { $.widget( "ui.autocomplete", {
version: "1.12.1", version: "1.13.0",
defaultElement: "<input>", defaultElement: "<input>",
options: { options: {
appendTo: null, appendTo: null,

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,7 @@
"name": "jquery-ui", "name": "jquery-ui",
"title": "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.", "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", "homepage": "http://jqueryui.com",
"author": { "author": {
"name": "jQuery Foundation and other contributors", "name": "jQuery Foundation and other contributors",

View File

@ -255,6 +255,9 @@ class StockHistoryCode(StatusCode):
# Stock merging operations # Stock merging operations
MERGED_STOCK_ITEMS = 45 MERGED_STOCK_ITEMS = 45
# Convert stock item to variant
CONVERTED_TO_VARIANT = 48
# Build order codes # Build order codes
BUILD_OUTPUT_CREATED = 50 BUILD_OUTPUT_CREATED = 50
BUILD_OUTPUT_COMPLETED = 55 BUILD_OUTPUT_COMPLETED = 55
@ -294,6 +297,8 @@ class StockHistoryCode(StatusCode):
MERGED_STOCK_ITEMS: _('Merged stock items'), MERGED_STOCK_ITEMS: _('Merged stock items'),
CONVERTED_TO_VARIANT: _('Converted to variant'),
SENT_TO_CUSTOMER: _('Sent to customer'), SENT_TO_CUSTOMER: _('Sent to customer'),
RETURNED_FROM_CUSTOMER: _('Returned from 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 # Change this number as more javascript files are added to the index page
N_SCRIPT_FILES = 38 N_SCRIPT_FILES = 39
content = self.get_index_page() content = self.get_index_page()

View File

@ -1,4 +1,5 @@
""" Version information for InvenTree. """
Version information for InvenTree.
Provides information on the current InvenTree version Provides information on the current InvenTree version
""" """
@ -8,141 +9,11 @@ import re
import common.models import common.models
from InvenTree.api_version import INVENTREE_API_VERSION
# InvenTree software version # InvenTree software version
INVENTREE_SW_VERSION = "0.7.0 dev" INVENTREE_SW_VERSION = "0.7.0 dev"
# InvenTree API version
INVENTREE_API_VERSION = 36
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v36 -> 2022-04-03
- Adds ability to filter part list endpoint by unallocated_stock argument
v35 -> 2022-04-01 : https://github.com/inventree/InvenTree/pull/2797
- Adds stock allocation information to the Part API
- Adds calculated field for "unallocated_quantity"
v34 -> 2022-03-25
- Change permissions for "plugin list" API endpoint (now allows any authenticated user)
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(): def inventreeInstanceName():
""" Returns the InstanceName settings for the current database """ """ Returns the InstanceName settings for the current database """

View File

@ -871,6 +871,9 @@ class Build(MPTTModel, ReferenceIndexingMixin):
part__in=[p for p in available_parts], 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: if location:
# Filter only stock items located "below" the specified location # Filter only stock items located "below" the specified location
sublocations = location.get_descendants(include_self=True) sublocations = location.get_descendants(include_self=True)

View File

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

View File

@ -932,6 +932,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'STOCK_BATCH_CODE_TEMPLATE': {
'name': _('Batch Code Template'),
'description': _('Template for generating default batch codes for stock items'),
'default': '',
},
'STOCK_ENABLE_EXPIRY': { 'STOCK_ENABLE_EXPIRY': {
'name': _('Stock Expiry'), 'name': _('Stock Expiry'),
'description': _('Enable stock expiry functionality'), 'description': _('Enable stock expiry functionality'),

View File

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

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

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

View File

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

View File

@ -262,6 +262,15 @@ class CategoryTree(generics.ListAPIView):
ordering = ['level', 'name'] 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): class PartSalePriceList(generics.ListCreateAPIView):
""" """
API endpoint for list view of PartSalePriceBreak model 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): class PartInternalPriceList(generics.ListCreateAPIView):
""" """
API endpoint for list view of PartInternalPriceBreak model API endpoint for list view of PartInternalPriceBreak model
@ -1175,6 +1193,18 @@ class PartList(generics.ListCreateAPIView):
except (ValueError, Part.DoesNotExist): except (ValueError, Part.DoesNotExist):
pass 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 # Filter only parts which are in the "BOM" for a given part
in_bom_for = params.get('in_bom_for', None) in_bom_for = params.get('in_bom_for', None)
@ -1339,10 +1369,6 @@ class PartList(generics.ListCreateAPIView):
filters.OrderingFilter, filters.OrderingFilter,
] ]
filter_fields = [
'variant_of',
]
ordering_fields = [ ordering_fields = [
'name', 'name',
'creation_date', 'creation_date',
@ -1602,9 +1628,10 @@ class BomList(generics.ListCreateAPIView):
def get_queryset(self, *args, **kwargs): 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().setup_eager_loading(queryset)
queryset = self.get_serializer_class().annotate_queryset(queryset)
return queryset return queryset
@ -1818,6 +1845,15 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = BomItem.objects.all() queryset = BomItem.objects.all()
serializer_class = part_serializers.BomItemSerializer 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): class BomItemValidate(generics.UpdateAPIView):
""" API endpoint for validating a BomItem """ """ API endpoint for validating a BomItem """
@ -1902,11 +1938,13 @@ part_api_urls = [
# Base URL for part sale pricing # Base URL for part sale pricing
url(r'^sale-price/', include([ 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'), url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
])), ])),
# Base URL for part internal pricing # Base URL for part internal pricing
url(r'^internal-price/', include([ 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'), url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
])), ])),

View File

@ -7,6 +7,7 @@
part: 100 part: 100
sub_part: 1 sub_part: 1
quantity: 10 quantity: 10
allow_variants: True
# 40 x R_2K2_0805 # 40 x R_2K2_0805
- model: part.bomitem - model: part.bomitem

View File

@ -177,6 +177,7 @@
fields: fields:
name: 'Green chair variant' name: 'Green chair variant'
variant_of: 10003 variant_of: 10003
is_template: true
category: 7 category: 7
trackable: true trackable: true
tree_id: 1 tree_id: 1

View File

@ -777,7 +777,8 @@ class Part(MPTTModel):
# User can decide whether duplicate IPN (Internal Part Number) values are allowed # User can decide whether duplicate IPN (Internal Part Number) values are allowed
allow_duplicate_ipn = common.models.InvenTreeSetting.get_setting('PART_ALLOW_DUPLICATE_IPN') 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 = Part.objects.filter(IPN__iexact=self.IPN)
parts = parts.exclude(pk=self.pk) parts = parts.exclude(pk=self.pk)
@ -798,6 +799,10 @@ class Part(MPTTModel):
super().clean() super().clean()
# Strip IPN field
if type(self.IPN) is str:
self.IPN = self.IPN.strip()
if self.trackable: if self.trackable:
for part in self.get_used_in().all(): for part in self.get_used_in().all():
@ -1313,19 +1318,31 @@ class Part(MPTTModel):
return quantity 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 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 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( total=Coalesce(
Sum( Sum(
'quantity', 'quantity',
@ -1343,7 +1360,19 @@ class Part(MPTTModel):
Return all sales-order-allocation objects which allocate this part to a SalesOrder 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)
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 # Default behaviour is to only return *pending* allocations
pending = kwargs.get('pending', True) pending = kwargs.get('pending', True)
@ -1381,7 +1410,7 @@ class Part(MPTTModel):
return query['total'] return query['total']
def allocation_count(self): def allocation_count(self, **kwargs):
""" """
Return the total quantity of stock allocated for this part, Return the total quantity of stock allocated for this part,
against both build orders and sales orders. against both build orders and sales orders.
@ -1389,8 +1418,8 @@ class Part(MPTTModel):
return sum( return sum(
[ [
self.build_order_allocation_count(), self.build_order_allocation_count(**kwargs),
self.sales_order_allocation_count(), self.sales_order_allocation_count(**kwargs),
], ],
) )
@ -2703,7 +2732,21 @@ class BomItem(models.Model, DataImportMixin):
for sub in self.substitutes.all(): for sub in self.substitutes.all():
parts.add(sub.part) parts.add(sub.part)
return parts valid_parts = []
for p in parts:
# Inactive parts cannot be 'auto allocated'
if not p.active:
continue
# Trackable parts cannot be 'auto allocated'
if p.trackable:
continue
valid_parts.append(p)
return valid_parts
def is_stock_item_valid(self, stock_item): def is_stock_item_valid(self, stock_item):
""" """
@ -2882,23 +2925,6 @@ class BomItem(models.Model, DataImportMixin):
child=self.sub_part.full_name, child=self.sub_part.full_name,
n=decimal2string(self.quantity)) 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): def get_overage_quantity(self, quantity):
""" Calculate overage quantity """ Calculate overage quantity
""" """

View File

@ -7,7 +7,9 @@ from decimal import Decimal
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.db import models, transaction from django.db import models, transaction
from django.db.models import ExpressionWrapper, F, 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.db.models.functions import Coalesce
from django.utils.translation import ugettext_lazy as _ 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 sql_util.utils import SubqueryCount, SubquerySum
from djmoney.contrib.django_rest_framework import MoneyField from djmoney.contrib.django_rest_framework import MoneyField
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.serializers import (DataFileUploadSerializer, from InvenTree.serializers import (DataFileUploadSerializer,
DataFileExtractSerializer, DataFileExtractSerializer,
InvenTreeAttachmentSerializerField, InvenTreeAttachmentSerializerField,
@ -146,6 +150,13 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
allow_null=True 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) price_string = serializers.CharField(source='price', read_only=True)
class Meta: class Meta:
@ -155,6 +166,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
'part', 'part',
'quantity', 'quantity',
'price', 'price',
'price_currency',
'price_string', 'price_string',
] ]
@ -170,6 +182,13 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
allow_null=True 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) price_string = serializers.CharField(source='price', read_only=True)
class Meta: class Meta:
@ -179,6 +198,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
'part', 'part',
'quantity', 'quantity',
'price', 'price',
'price_currency',
'price_string', 'price_string',
] ]
@ -308,9 +328,6 @@ class PartSerializer(InvenTreeModelSerializer):
to reduce database trips. 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 # Annotate with the total 'in stock' quantity
queryset = queryset.annotate( queryset = queryset.annotate(
in_stock=Coalesce( in_stock=Coalesce(
@ -325,6 +342,24 @@ class PartSerializer(InvenTreeModelSerializer):
stock_item_count=SubqueryCount('stock_items') 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" # Filter to limit builds to "active"
build_filter = Q( build_filter = Q(
status__in=BuildStatus.ACTIVE_CODES status__in=BuildStatus.ACTIVE_CODES
@ -429,6 +464,7 @@ class PartSerializer(InvenTreeModelSerializer):
unallocated_stock = serializers.FloatField(read_only=True) unallocated_stock = serializers.FloatField(read_only=True)
building = serializers.FloatField(read_only=True) building = serializers.FloatField(read_only=True)
in_stock = 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) ordering = serializers.FloatField(read_only=True)
stock_item_count = serializers.IntegerField(read_only=True) stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True) suppliers = serializers.IntegerField(read_only=True)
@ -463,6 +499,7 @@ class PartSerializer(InvenTreeModelSerializer):
'full_name', 'full_name',
'image', 'image',
'in_stock', 'in_stock',
'variant_stock',
'ordering', 'ordering',
'building', 'building',
'IPN', 'IPN',
@ -577,6 +614,11 @@ class BomItemSerializer(InvenTreeModelSerializer):
purchase_price_range = serializers.SerializerMethodField() 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): def __init__(self, *args, **kwargs):
# part_detail and sub_part_detail serializers are only included if requested. # part_detail and sub_part_detail serializers are only included if requested.
# This saves a bunch of database requests # This saves a bunch of database requests
@ -609,10 +651,158 @@ class BomItemSerializer(InvenTreeModelSerializer):
queryset = queryset.prefetch_related('sub_part') queryset = queryset.prefetch_related('sub_part')
queryset = queryset.prefetch_related('sub_part__category') 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') queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
return queryset 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): def get_purchase_price_range(self, obj):
""" Return purchase price range """ """ Return purchase price range """
@ -682,6 +872,12 @@ class BomItemSerializer(InvenTreeModelSerializer):
'substitutes', 'substitutes',
'price_range', 'price_range',
'validated', '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

@ -192,6 +192,15 @@
</div> </div>
</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 panel-hidden' id='panel-parameters'>
<div class='panel-heading'> <div class='panel-heading'>
<h4>{% trans "Part Parameters" %}</h4> <h4>{% trans "Part Parameters" %}</h4>
@ -228,6 +237,21 @@
{{ block.super }} {{ block.super }}
{% if category %} {% 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() { onPanelLoad('parameters', function() {
loadParametricPartTable( loadParametricPartTable(
"#parametric-part-table", "#parametric-part-table",

View File

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

View File

@ -3,7 +3,6 @@
{% load i18n %} {% load i18n %}
{% load inventree_extras %} {% load inventree_extras %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load markdownify %}
{% block sidebar %} {% block sidebar %}
{% include 'part/part_sidebar.html' %} {% include 'part/part_sidebar.html' %}
@ -125,8 +124,7 @@
</div> </div>
</div> </div>
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %} {% if part.purchaseable or part.salable %}
{% if show_price_history %}
<div class='panel panel-hidden' id='panel-pricing'> <div class='panel panel-hidden' id='panel-pricing'>
{% include "part/prices.html" %} {% include "part/prices.html" %}
</div> </div>
@ -134,24 +132,16 @@
<div class='panel panel-hidden' id='panel-part-notes'> <div class='panel panel-hidden' id='panel-part-notes'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='row'> <div class='d-flex flex-wrap'>
<div class='col-sm-6'> <h4>{% trans "Part Notes" %}</h4>
<h4>{% trans "Notes" %}</h4> {% include "spacer.html" %}
</div> <div class='btn-group' role='group'>
<div class='col-sm-6'> {% include "notes_buttons.html" %}
<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> </div>
</div> </div>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
{% if part.notes %} <textarea id='part-notes'></textarea>
{{ part.notes | markdownify }}
{% endif %}
</div> </div>
</div> </div>
@ -419,6 +409,18 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ 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 // Load the "scheduling" tab
onPanelLoad('scheduling', function() { onPanelLoad('scheduling', function() {
loadPartSchedulingChart('part-schedule-chart', {{ part.pk }}); 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() { onPanelLoad("part-parameters", function() {
loadPartParameterTable( loadPartParameterTable(
'#parameter-table', '#parameter-table',
@ -1036,7 +1008,7 @@
pb_url_slug: 'internal-price', pb_url_slug: 'internal-price',
pb_url: '{% url 'api-part-internal-price-list' %}', pb_url: '{% url 'api-part-internal-price-list' %}',
pb_new_btn: $('#new-internal-price-break'), 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'), linkedGraph: $('#InternalPriceBreakChart'),
}, },
); );
@ -1052,7 +1024,7 @@
pb_url_slug: 'sale-price', pb_url_slug: 'sale-price',
pb_url: "{% url 'api-part-sale-price-list' %}", pb_url: "{% url 'api-part-sale-price-list' %}",
pb_new_btn: $('#new-price-break'), 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'), linkedGraph: $('#SalePriceBreakChart'),
}, },
); );

View File

@ -252,7 +252,6 @@
</tr> </tr>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if not part.is_template %}
{% if part.assembly %} {% if part.assembly %}
<tr> <tr>
<td><span class='fas fa-tools'></span></td> <td><span class='fas fa-tools'></span></td>
@ -267,7 +266,6 @@
</tr> </tr>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endif %}
</table> </table>
{% endblock details_right %} {% endblock details_right %}

View File

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

View File

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

View File

@ -9,7 +9,7 @@ from rest_framework import status
from rest_framework.test import APIClient from rest_framework.test import APIClient
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import BuildStatus, StockStatus from InvenTree.status_codes import BuildStatus, StockStatus, PurchaseOrderStatus
from part.models import Part, PartCategory from part.models import Part, PartCategory
from part.models import BomItem, BomItemSubstitute from part.models import BomItem, BomItemSubstitute
@ -567,6 +567,185 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(response.data['name'], name) self.assertEqual(response.data['name'], name)
self.assertEqual(response.data['description'], description) 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): class PartDetailTests(InvenTreeAPITestCase):
""" """
@ -578,7 +757,12 @@ class PartDetailTests(InvenTreeAPITestCase):
'part', 'part',
'location', 'location',
'bom', 'bom',
'company',
'test_templates', 'test_templates',
'manufacturer_part',
'supplier_part',
'order',
'stock',
] ]
roles = [ roles = [
@ -805,6 +989,38 @@ class PartDetailTests(InvenTreeAPITestCase):
# And now check that the image has been set # And now check that the image has been set
p = Part.objects.get(pk=pk) 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): class PartAPIAggregationTest(InvenTreeAPITestCase):
""" """
@ -1123,6 +1339,12 @@ class BomItemTest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), 1) self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['pk'], bom_item.pk) 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): def test_get_bom_detail(self):
""" """
Get the detail view for a single BomItem object Get the detail view for a single BomItem object
@ -1132,6 +1354,26 @@ class BomItemTest(InvenTreeAPITestCase):
response = self.get(url, expected_code=200) 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) self.assertEqual(int(float(response.data['quantity'])), 25)
# Increase the quantity # Increase the quantity
@ -1319,6 +1561,21 @@ class BomItemTest(InvenTreeAPITestCase):
response = self.get(url, expected_code=200) response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 5) 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): def test_bom_item_uses(self):
""" """
Tests for the 'uses' field Tests for the 'uses' field
@ -1372,6 +1629,44 @@ class BomItemTest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), i) 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): class PartParameterTest(InvenTreeAPITestCase):
""" """

View File

@ -349,6 +349,26 @@ class PartSettingsTest(TestCase):
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C') part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C')
part.full_clean() 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): class PartSubscriptionTests(TestCase):

View File

@ -13,18 +13,6 @@ from django.conf.urls import url, include
from . import views 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 = [ part_parameter_urls = [
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'), 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'), url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
@ -86,12 +74,6 @@ part_urls = [
# Part category # Part category
url(r'^category/', include(category_urls)), 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 # Part parameters
url(r'^parameter/', include(part_parameter_urls)), 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.conf import settings
from django.contrib import messages from django.contrib import messages
from moneyed import CURRENCIES
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.exceptions import MissingRate
@ -33,7 +32,6 @@ from decimal import Decimal
from .models import PartCategory, Part from .models import PartCategory, Part
from .models import PartParameterTemplate from .models import PartParameterTemplate
from .models import PartCategoryParameterTemplate from .models import PartCategoryParameterTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from company.models import SupplierPart from company.models import SupplierPart
@ -389,8 +387,12 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
context.update(**ctx) context.update(**ctx)
show_price_history = InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False)
context['show_price_history'] = show_price_history
# Pricing information # Pricing information
if InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False): if show_price_history:
ctx = self.get_pricing(self.get_quantity()) ctx = self.get_pricing(self.get_quantity())
ctx['form'] = self.form_class(initial=self.get_initials()) ctx['form'] = self.form_class(initial=self.get_initials())
@ -1226,102 +1228,3 @@ class CategoryParameterTemplateDelete(AjaxDeleteView):
return None return None
return self.object 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

@ -94,6 +94,14 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
""" """
return getattr(self, 'is_package', False) 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 # region properties
@property @property
def slug(self): def slug(self):

View File

@ -4,7 +4,6 @@
{% load report %} {% load report %}
{% load barcode %} {% load barcode %}
{% load inventree_extras %} {% load inventree_extras %}
{% load markdownify %}
{% block page_margin %} {% block page_margin %}
margin: 2cm; margin: 2cm;

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') serialized = rest_filters.BooleanFilter(label='Has serial number', method='filter_serialized')
def filter_serialized(self, queryset, name, value): 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): if str2bool(value):
queryset = queryset.exclude(serial=None) queryset = queryset.exclude(q)
else: 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 return queryset
@ -1105,7 +1145,6 @@ class StockItemTestResultList(generics.ListCreateAPIView):
] ]
filter_fields = [ filter_fields = [
'stock_item',
'test', 'test',
'user', 'user',
'result', 'result',
@ -1114,6 +1153,38 @@ class StockItemTestResultList(generics.ListCreateAPIView):
ordering = 'date' 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): def get_serializer(self, *args, **kwargs):
try: try:
kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False)) kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False))
@ -1189,6 +1260,15 @@ class StockTrackingList(generics.ListAPIView):
if not deltas: if not deltas:
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 # Add location detail
if 'location' in deltas: if 'location' in deltas:
try: try:

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 import os
from jinja2 import Template
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError, FieldError from django.core.exceptions import ValidationError, FieldError
from django.urls import reverse 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): class StockItem(MPTTModel):
""" """
A StockItem object represents a quantity of physical instances of a part. A StockItem object represents a quantity of physical instances of a part.
@ -453,6 +481,14 @@ class StockItem(MPTTModel):
super().clean() 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: try:
if self.part.trackable: if self.part.trackable:
# Trackable parts must have integer values for quantity field! # Trackable parts must have integer values for quantity field!
@ -636,7 +672,8 @@ class StockItem(MPTTModel):
batch = models.CharField( batch = models.CharField(
verbose_name=_('Batch Code'), verbose_name=_('Batch Code'),
max_length=100, blank=True, null=True, 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( quantity = models.DecimalField(
@ -718,6 +755,33 @@ class StockItem(MPTTModel):
help_text=_('Select Owner'), help_text=_('Select Owner'),
related_name='stock_items') 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): def get_item_owner(self):
""" """
Return the closest "owner" for this StockItem. Return the closest "owner" for this StockItem.

View File

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

View File

@ -210,6 +210,46 @@ class StockItemListTest(StockAPITestCase):
for item in response: for item in response:
self.assertIsNone(item['serial']) 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): def test_filter_by_expired(self):
""" """
Filter StockItem by expiry status Filter StockItem by expiry status

View File

@ -644,6 +644,16 @@ class StockItemConvert(AjaxUpdateView):
return form 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): class StockLocationCreate(AjaxCreateView):
""" """

View File

@ -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_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_NAVIGATION" icon="fa-sitemap" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %} {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %}
{% include "InvenTree/settings/setting.html" with key="PLUGIN_ON_STARTUP" %}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -76,6 +77,12 @@
{% endfor %} {% endfor %}
{% endif %} {% 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 %} {% if plugin.website %}
<a href="{{ plugin.website }}"><span class="fas fa-globe"></span></a> <a href="{{ plugin.website }}"><span class="fas fa-globe"></span></a>
{% endif %} {% endif %}

View File

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

View File

@ -11,6 +11,7 @@
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <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_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_STALE_DAYS" icon="fa-calendar" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %} {% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %}

View File

@ -48,6 +48,7 @@
<link rel="stylesheet" href="{% static 'select2/css/select2-bootstrap-5-theme.css' %}"> <link rel="stylesheet" href="{% static 'select2/css/select2-bootstrap-5-theme.css' %}">
<link rel="stylesheet" href="{% static 'fullcalendar/main.css' %}"> <link rel="stylesheet" href="{% static 'fullcalendar/main.css' %}">
<link rel="stylesheet" href="{% static 'script/jquery-ui/jquery-ui.min.css' %}"> <link rel="stylesheet" href="{% static 'script/jquery-ui/jquery-ui.min.css' %}">
<link rel="stylesheet" href="{% static 'easymde/easymde.min.css' %}">
<link rel="stylesheet" href="{% static 'css/inventree.css' %}"> <link rel="stylesheet" href="{% static 'css/inventree.css' %}">
@ -160,6 +161,7 @@
<script type='text/javascript' src="{% static 'script/chart.js' %}"></script> <script type='text/javascript' src="{% static 'script/chart.js' %}"></script>
<script type='text/javascript' src="{% static 'script/moment.js' %}"></script> <script type='text/javascript' src="{% static 'script/moment.js' %}"></script>
<script type='text/javascript' src="{% static 'script/chartjs-adapter-moment.js' %}"></script> <script type='text/javascript' src="{% static 'script/chartjs-adapter-moment.js' %}"></script>
<script type='text/javascript' src="{% static 'easymde/easymde.min.js' %}"></script>
<script type='text/javascript' src="{% static 'script/clipboard.min.js' %}"></script> <script type='text/javascript' src="{% static 'script/clipboard.min.js' %}"></script>
<script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script> <script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script>

View File

@ -798,17 +798,38 @@ function loadBomTable(table, options={}) {
}); });
cols.push({ cols.push({
field: 'sub_part_detail.stock', field: 'available_stock',
title: '{% trans "Available" %}', title: '{% trans "Available" %}',
searchable: false, searchable: false,
sortable: true, sortable: true,
formatter: function(value, row) { formatter: function(value, row) {
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`; var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
var text = value;
if (value == null || value <= 0) { // Calculate total "available" (unallocated) quantity
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock" %}</span>`; var base_stock = row.available_stock;
var substitute_stock = row.available_substitute_stock || 0;
var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0;
var available_stock = base_stock + substitute_stock + variant_stock;
var text = `${available_stock}`;
if (available_stock <= 0) {
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
} else {
var extra = '';
if ((substitute_stock > 0) && (variant_stock > 0)) {
extra = '{% trans "Includes variant and substitute stock" %}';
} else if (variant_stock > 0) {
extra = '{% trans "Includes variant stock" %}';
} else if (substitute_stock > 0) {
extra = '{% trans "Includes substitute stock" %}';
}
if (extra) {
text += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
}
} }
return renderLink(text, url); return renderLink(text, url);
@ -902,8 +923,10 @@ function loadBomTable(table, options={}) {
formatter: function(value, row) { formatter: function(value, row) {
var can_build = 0; var can_build = 0;
var available = row.available_stock + (row.available_substitute_stock || 0) + (row.available_variant_stock || 0);
if (row.quantity > 0) { if (row.quantity > 0) {
can_build = row.sub_part_detail.stock / row.quantity; can_build = available / row.quantity;
} }
return +can_build.toFixed(2); return +can_build.toFixed(2);
@ -914,11 +937,11 @@ function loadBomTable(table, options={}) {
var cb_b = 0; var cb_b = 0;
if (rowA.quantity > 0) { if (rowA.quantity > 0) {
cb_a = rowA.sub_part_detail.stock / rowA.quantity; cb_a = (rowA.available_stock + rowA.available_substitute_stock) / rowA.quantity;
} }
if (rowB.quantity > 0) { if (rowB.quantity > 0) {
cb_b = rowB.sub_part_detail.stock / rowB.quantity; cb_b = (rowB.available_stock + rowB.available_substitute_stock) / rowB.quantity;
} }
return (cb_a > cb_b) ? 1 : -1; return (cb_a > cb_b) ? 1 : -1;

View File

@ -1421,9 +1421,41 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
sortable: true, sortable: true,
}, },
{ {
field: 'sub_part_detail.stock', field: 'available_stock',
title: '{% trans "Available" %}', title: '{% trans "Available" %}',
sortable: true, sortable: true,
formatter: function(value, row) {
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
// Calculate total "available" (unallocated) quantity
var base_stock = row.available_stock;
var substitute_stock = row.available_substitute_stock || 0;
var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0;
var available_stock = base_stock + substitute_stock + variant_stock;
var text = `${available_stock}`;
if (available_stock <= 0) {
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
} else {
var extra = '';
if ((substitute_stock > 0) && (variant_stock > 0)) {
extra = '{% trans "Includes variant and substitute stock" %}';
} else if (variant_stock > 0) {
extra = '{% trans "Includes variant stock" %}';
} else if (substitute_stock > 0) {
extra = '{% trans "Includes substitute stock" %}';
}
if (extra) {
text += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
}
}
return renderLink(text, url);
}
}, },
{ {
field: 'allocated', field: 'allocated',

View File

@ -10,6 +10,7 @@
makeProgressBar, makeProgressBar,
renderLink, renderLink,
select2Thumbnail, select2Thumbnail,
setupNotesField,
thumbnailImage thumbnailImage
yesNoLabel, yesNoLabel,
*/ */
@ -221,3 +222,93 @@ function renderLink(text, url, options={}) {
return `<a href="${url}">${text}</a>`; return `<a href="${url}">${text}</a>`;
} }
function setupNotesField(element, url, options={}) {
var editable = options.editable || false;
// Read initial notes value from the URL
var initial = null;
inventreeGet(url, {}, {
async: false,
success: function(response) {
initial = response[options.notes_field || 'notes'];
},
});
var toolbar_icons = [
'preview', '|',
];
if (editable) {
// Heading icons
toolbar_icons.push('heading-1', 'heading-2', 'heading-3', '|');
// Font style
toolbar_icons.push('bold', 'italic', 'strikethrough', '|');
// Text formatting
toolbar_icons.push('unordered-list', 'ordered-list', 'code', 'quote', '|');
// Elements
toolbar_icons.push('table', 'link', 'image');
}
// Markdown syntax guide
toolbar_icons.push('|', 'guide');
const mde = new EasyMDE({
element: document.getElementById(element),
initialValue: initial,
toolbar: toolbar_icons,
shortcuts: [],
});
// Hide the toolbar
$(`#${element}`).next('.EasyMDEContainer').find('.editor-toolbar').hide();
if (!editable) {
// Set readonly
mde.codemirror.setOption('readOnly', true);
// Hide the "edit" and "save" buttons
$('#edit-notes').hide();
$('#save-notes').hide();
} else {
mde.togglePreview();
// Add callback for "edit" button
$('#edit-notes').click(function() {
$('#edit-notes').hide();
$('#save-notes').show();
// Show the toolbar
$(`#${element}`).next('.EasyMDEContainer').find('.editor-toolbar').show();
mde.togglePreview();
});
// Add callback for "save" button
$('#save-notes').click(function() {
var data = {};
data[options.notes_field || 'notes'] = mde.value();
inventreePut(url, data, {
method: 'PATCH',
success: function(response) {
showMessage('{% trans "Notes updated" %}', {style: 'success'});
},
error: function(xhr) {
showApiError(xhr, url);
}
});
});
}
}

View File

@ -99,14 +99,22 @@ function renderStockItem(name, data, parameters={}, options={}) {
var stock_detail = ''; var stock_detail = '';
if (data.quantity == 0) {
stock_detail = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock"% }</span>`;
} else {
if (data.serial && data.quantity == 1) { if (data.serial && data.quantity == 1) {
stock_detail = `{% trans "Serial Number" %}: ${data.serial}`; stock_detail = `{% trans "Serial Number" %}: ${data.serial}`;
} else if (data.quantity == 0) {
stock_detail = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock"% }</span>`;
} else { } else {
stock_detail = `{% trans "Quantity" %}: ${data.quantity}`; stock_detail = `{% trans "Quantity" %}: ${data.quantity}`;
} }
if (data.batch) {
stock_detail += ` - <small>{% trans "Batch" %}: ${data.batch}</small>`;
}
}
var html = ` var html = `
<span> <span>
${part_detail} ${part_detail}
@ -193,7 +201,7 @@ function renderPart(name, data, parameters={}, options={}) {
<small> <small>
${stock_data} ${stock_data}
${extra} ${extra}
${renderId('{% trans "Part ID" $}', data.pk, parameters)} ${renderId('{% trans "Part ID" %}', data.pk, parameters)}
</small> </small>
</span>`; </span>`;

View File

@ -171,6 +171,9 @@ function notificationCheck(force = false) {
{ {
success: function(response) { success: function(response) {
updateNotificationIndicator(response.length); updateNotificationIndicator(response.length);
},
error: function(xhr) {
console.warn('Could not access server: /api/notifications');
} }
} }
); );

View File

@ -293,6 +293,7 @@ function categoryFields() {
return { return {
parent: { parent: {
help_text: '{% trans "Parent part category" %}', help_text: '{% trans "Parent part category" %}',
required: false,
}, },
name: {}, name: {},
description: {}, description: {},
@ -373,6 +374,9 @@ function duplicatePart(pk, options={}) {
// Override the "variant_of" field // Override the "variant_of" field
data.variant_of = pk; data.variant_of = pk;
// By default, disable "is_template" when making a variant *of* a template
data.is_template = false;
} }
constructForm('{% url "api-part-list" %}', { constructForm('{% url "api-part-list" %}', {
@ -668,7 +672,20 @@ function loadPartVariantTable(table, partId, options={}) {
field: 'in_stock', field: 'in_stock',
title: '{% trans "Stock" %}', title: '{% trans "Stock" %}',
formatter: function(value, row) { formatter: function(value, row) {
return renderLink(value, `/part/${row.pk}/?display=part-stock`);
var base_stock = row.in_stock;
var variant_stock = row.variant_stock || 0;
var total = base_stock + variant_stock;
var text = `${total}`;
if (variant_stock > 0) {
text = `<em>${text}</em>`;
text += `<span title='{% trans "Includes variant stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`;
}
return renderLink(text, `/part/${row.pk}/?display=part-stock`);
} }
} }
]; ];
@ -1900,7 +1917,9 @@ function loadPriceBreakTable(table, options) {
formatNoMatches: function() { formatNoMatches: function() {
return `{% trans "No ${human_name} information found" %}`; return `{% trans "No ${human_name} information found" %}`;
}, },
queryParams: {part: options.part}, queryParams: {
part: options.part
},
url: options.url, url: options.url,
onLoadSuccess: function(tableData) { onLoadSuccess: function(tableData) {
if (linkedGraph) { if (linkedGraph) {
@ -2006,36 +2025,45 @@ function initPriceBreakSet(table, options) {
} }
pb_new_btn.click(function() { pb_new_btn.click(function() {
launchModalForm(pb_new_url,
{ constructForm(pb_new_url, {
success: reloadPriceBreakTable, fields: {
data: { part: {
part: part_id, hidden: true,
} value: part_id,
} },
); quantity: {},
price: {},
price_currency: {},
},
method: 'POST',
title: '{% trans "Add Price Break" %}',
onSuccess: reloadPriceBreakTable,
});
}); });
table.on('click', `.button-${pb_url_slug}-delete`, function() { table.on('click', `.button-${pb_url_slug}-delete`, function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
launchModalForm( constructForm(`${pb_url}${pk}/`, {
`/part/${pb_url_slug}/${pk}/delete/`, method: 'DELETE',
{ title: '{% trans "Delete Price Break" %}',
success: reloadPriceBreakTable onSuccess: reloadPriceBreakTable,
} });
);
}); });
table.on('click', `.button-${pb_url_slug}-edit`, function() { table.on('click', `.button-${pb_url_slug}-edit`, function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
launchModalForm( constructForm(`${pb_url}${pk}/`, {
`/part/${pb_url_slug}/${pk}/edit/`, fields: {
{ quantity: {},
success: reloadPriceBreakTable price: {},
} price_currency: {},
); },
title: '{% trans "Edit Price Break" %}',
onSuccess: reloadPriceBreakTable,
});
}); });
} }

View File

@ -107,6 +107,7 @@ function stockLocationFields(options={}) {
var fields = { var fields = {
parent: { parent: {
help_text: '{% trans "Parent stock location" %}', help_text: '{% trans "Parent stock location" %}',
required: false,
}, },
name: {}, name: {},
description: {}, description: {},
@ -240,9 +241,11 @@ function stockItemFields(options={}) {
serial: { serial: {
icon: 'fa-hashtag', icon: 'fa-hashtag',
}, },
batch: {
icon: 'fa-layer-group',
},
status: {}, status: {},
expiry_date: {}, expiry_date: {},
batch: {},
purchase_price: { purchase_price: {
icon: 'fa-dollar-sign', icon: 'fa-dollar-sign',
}, },
@ -963,6 +966,10 @@ function adjustStock(action, items, options={}) {
quantity = `#${item.serial}`; quantity = `#${item.serial}`;
} }
if (item.batch) {
quantity += ` - <small>{% trans "Batch" %}: ${item.batch}</small>`;
}
var actionInput = ''; var actionInput = '';
if (actionTitle != null) { if (actionTitle != null) {
@ -1331,14 +1338,27 @@ function loadStockTestResultsTable(table, options) {
}); });
// Once the test template data are loaded, query for test results // Once the test template data are loaded, query for test results
inventreeGet(
'{% url "api-stock-test-result-list" %}', var filters = loadTableFilters(filterKey);
{
var query_params = {
stock_item: options.stock_item, stock_item: options.stock_item,
user_detail: true, user_detail: true,
attachment_detail: true, attachment_detail: true,
ordering: '-date', ordering: '-date',
}, };
if ('result' in filters) {
query_params.result = filters.result;
}
if ('include_installed' in filters) {
query_params.include_installed = filters.include_installed;
}
inventreeGet(
'{% url "api-stock-test-result-list" %}',
query_params,
{ {
success: function(data) { success: function(data) {
// Iterate through the returned test data // Iterate through the returned test data
@ -2301,6 +2321,23 @@ function loadStockTrackingTable(table, options) {
var cols = []; var cols = [];
var filterTarget = '#filter-list-stocktracking';
var filterKey = 'stocktracking';
var filters = loadTableFilters(filterKey);
var params = options.params;
var original = {};
for (var k in params) {
original[k] = params[k];
filters[k] = params[k];
}
setupFilterList(filterKey, table, filterTarget);
// Date // Date
cols.push({ cols.push({
field: 'date', field: 'date',
@ -2338,6 +2375,19 @@ function loadStockTrackingTable(table, options) {
return html; return html;
} }
// Part information
if (details.part) {
html += `<tr><th>{% trans "Part" %}</th><td>`;
if (details.part_detail) {
html += renderLink(details.part_detail.full_name, `/part/${details.part}/`);
} else {
html += `{% trans "Part information unavailable" %}`;
}
html += `</td></tr>`;
}
// Location information // Location information
if (details.location) { if (details.location) {
@ -2475,27 +2525,10 @@ function loadStockTrackingTable(table, options) {
} }
}); });
/*
// 2021-05-11 - Ability to edit or delete StockItemTracking entries is now removed
cols.push({
sortable: false,
formatter: function(value, row, index, field) {
// Manually created entries can be edited or deleted
if (false && !row.system) {
var bEdit = "<button title='{% trans 'Edit tracking entry' %}' class='btn btn-entry-edit btn-outline-secondary' type='button' url='/stock/track/" + row.pk + "/edit/'><span class='fas fa-edit'/></button>";
var bDel = "<button title='{% trans 'Delete tracking entry' %}' class='btn btn-entry-delete btn-outline-secondary' type='button' url='/stock/track/" + row.pk + "/delete/'><span class='fas fa-trash-alt icon-red'/></button>";
return "<div class='btn-group' role='group'>" + bEdit + bDel + "</div>";
} else {
return "";
}
}
});
*/
table.inventreeTable({ table.inventreeTable({
method: 'get', method: 'get',
queryParams: options.params, queryParams: filters,
original: original,
columns: cols, columns: cols,
url: options.url, url: options.url,
}); });
@ -2626,7 +2659,8 @@ function installStockItem(stock_item_id, part_id, options={}) {
<ul> <ul>
<li>{% trans "The Stock Item links to a Part which is the BOM for this Stock Item" %}</li> <li>{% trans "The Stock Item links to a Part which is the BOM for this Stock Item" %}</li>
<li>{% trans "The Stock Item is currently available in stock" %}</li> <li>{% trans "The Stock Item is currently available in stock" %}</li>
<li>{% trans "The Stock Item is serialized and does not belong to another item" %}</li> <li>{% trans "The Stock Item is not already installed in another item" %}</li>
<li>{% trans "The Stock Item is tracked by either a batch code or serial number" %}</li>
</ul> </ul>
</div>`; </div>`;
@ -2652,7 +2686,7 @@ function installStockItem(stock_item_id, part_id, options={}) {
filters: { filters: {
part_detail: true, part_detail: true,
in_stock: true, in_stock: true,
serialized: true, tracked: true,
}, },
adjustFilters: function(filters, opts) { adjustFilters: function(filters, opts) {
var part = getFormFieldValue('part', {}, opts); var part = getFormFieldValue('part', {}, opts);

View File

@ -234,10 +234,19 @@ function getAvailableTableFilters(tableKey) {
title: '{% trans "Stock status" %}', title: '{% trans "Stock status" %}',
description: '{% trans "Stock status" %}', description: '{% trans "Stock status" %}',
}, },
has_batch: {
title: '{% trans "Has batch code" %}',
type: 'bool',
},
batch: { batch: {
title: '{% trans "Batch" %}', title: '{% trans "Batch" %}',
description: '{% trans "Batch code" %}', description: '{% trans "Batch code" %}',
}, },
tracked: {
title: '{% trans "Tracked" %}',
description: '{% trans "Stock item is tracked by either batch code or serial number" %}',
type: 'bool',
},
has_purchase_price: { has_purchase_price: {
type: 'bool', type: 'bool',
title: '{% trans "Has purchase price" %}', title: '{% trans "Has purchase price" %}',
@ -265,7 +274,16 @@ function getAvailableTableFilters(tableKey) {
// Filters for the 'stock test' table // Filters for the 'stock test' table
if (tableKey == 'stocktests') { if (tableKey == 'stocktests') {
return {}; return {
result: {
type: 'bool',
title: '{% trans "Test Passed" %}',
},
include_installed: {
type: 'bool',
title: '{% trans "Include Installed Items" %}',
}
};
} }
// Filters for the 'part test template' table // Filters for the 'part test template' table

View File

@ -0,0 +1,8 @@
{% load i18n %}
<button type='button' id='edit-notes' title='{% trans "Edit" %}' class='btn btn-primary'>
<span class='fas fa-edit'></span> {% trans "Edit" %}
</button>
<button type='button' id='save-notes' title='{% trans "Save" %}' class='btn btn-success' style='display: none;'>
<span class='fas fa-save'></span> {% trans "Save" %}
</button>

View File

@ -1,6 +1,6 @@
# InvenTree environment variables for a development setup # InvenTree environment variables for a development setup
# Set DEBUG to False for a production environment! # Set DEBUG to True for a development setup
INVENTREE_DEBUG=True INVENTREE_DEBUG=True
INVENTREE_DEBUG_LEVEL=INFO INVENTREE_DEBUG_LEVEL=INFO
@ -15,3 +15,5 @@ INVENTREE_DB_PASSWORD=pgpassword
# Enable plugins? # Enable plugins?
INVENTREE_PLUGINS_ENABLED=True INVENTREE_PLUGINS_ENABLED=True
COMPOSE_PROJECT_NAME=inventree-development

View File

@ -137,7 +137,6 @@ ENV INVENTREE_CONFIG_FILE="${INVENTREE_DEV_DIR}/config.yaml"
ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DEV_DIR}/secret_key.txt" ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DEV_DIR}/secret_key.txt"
ENV INVENTREE_PLUGIN_FILE="${INVENTREE_DEV_DIR}/plugins.txt" ENV INVENTREE_PLUGIN_FILE="${INVENTREE_DEV_DIR}/plugins.txt"
WORKDIR ${INVENTREE_HOME} WORKDIR ${INVENTREE_HOME}
# Entrypoint ensures that we are running in the python virtual environment # Entrypoint ensures that we are running in the python virtual environment

View File

@ -1,106 +0,0 @@
version: "3.8"
# Docker compose recipe for InvenTree development server
# - Runs PostgreSQL as the database backend
# - Uses built-in django webserver
# - Runs the InvenTree background worker process
# - Serves media and static content directly from Django webserver
# IMPORANT NOTE:
# The InvenTree docker image does not clone source code from git.
# Instead, you must specify *where* the source code is located,
# (on your local machine).
# The django server will auto-detect any code changes and reload the server.
services:
# Database service
# Use PostgreSQL as the database backend
# Note: This can be changed to a different backend if required
inventree-dev-db:
container_name: inventree-dev-db
image: postgres:13
ports:
- 5432/tcp
environment:
- PGDATA=/var/lib/postgresql/data/dev/pgdb
# The pguser and pgpassword values must be the same in the other containers
# Ensure that these are correctly configured in your dev-config.env file
- POSTGRES_USER=pguser
- POSTGRES_PASSWORD=pgpassword
volumes:
# Map 'data' volume such that postgres database is stored externally
- src:/var/lib/postgresql/data
restart: unless-stopped
# InvenTree web server services
# Uses gunicorn as the web server
inventree-dev-server:
container_name: inventree-dev-server
depends_on:
- inventree-dev-db
build:
context: .
target: dev
ports:
# Expose web server on port 8000
- 8000:8000
# Note: If using the inventree-dev-proxy container (see below),
# comment out the "ports" directive (above) and uncomment the "expose" directive
#expose:
# - 8000
volumes:
# Ensure you specify the location of the 'src' directory at the end of this file
- src:/home/inventree
env_file:
# Environment variables required for the dev server are configured in dev-config.env
- dev-config.env
restart: unless-stopped
# Background worker process handles long-running or periodic tasks
inventree-dev-worker:
container_name: inventree-dev-worker
build:
context: .
target: dev
command: invoke worker
depends_on:
- inventree-dev-server
volumes:
# Ensure you specify the location of the 'src' directory at the end of this file
- src:/home/inventree
env_file:
# Environment variables required for the dev server are configured in dev-config.env
- dev-config.env
restart: unless-stopped
### Optional: Serve static and media files using nginx
### Uncomment the following lines to enable nginx proxy for testing
### Note: If enabling the proxy, change "ports" to "expose" for the inventree-dev-server container (above)
#inventree-dev-proxy:
# container_name: inventree-dev-proxy
# image: nginx:stable
# depends_on:
# - inventree-dev-server
# ports:
# # Change "8000" to the port that you want InvenTree web server to be available on
# - 8000:80
# volumes:
# # Provide ./nginx.conf file to the container
# # Refer to the provided example file as a starting point
# - ./nginx.dev.conf:/etc/nginx/conf.d/default.conf:ro
# # nginx proxy needs access to static and media files
# - src:/var/www
# restart: unless-stopped
volumes:
# NOTE: Change "../" to a directory on your local machine, where the InvenTree source code is located
# Persistent data, stored external to the container(s)
src:
driver: local
driver_opts:
type: none
o: bind
# This directory specified where InvenTree source code is stored "outside" the docker containers
# By default, this directory is one level above the "docker" directory
device: ../

View File

@ -21,32 +21,34 @@ services:
build: build:
context: . context: .
target: dev target: dev
# Cache the built image to be used by the inventree-dev-worker process
image: inventree-dev-image
ports: ports:
# Expose web server on port 8000 # Expose web server on port 8000
- 8000:8000 - 8000:8000
volumes: volumes:
# Ensure you specify the location of the 'src' directory at the end of this file # Ensure you specify the location of the 'src' directory at the end of this file
- src:/home/inventree - src:/home/inventree
env_file: environment:
# Environment variables required for the dev server are configured in dev-config.env - INVENTREE_DEBUG=True
- sqlite-config.env - INVENTREE_DB_ENGINE=sqlite
- INVENTREE_DB_NAME=/home/inventree/db.sqlite3
restart: unless-stopped restart: unless-stopped
# Background worker process handles long-running or periodic tasks # Background worker process handles long-running or periodic tasks
inventree-dev-worker: inventree-dev-worker:
container_name: inventree-dev-worker container_name: inventree-dev-worker
build: image: inventree-dev-image
context: .
target: dev
command: invoke worker command: invoke worker
depends_on: depends_on:
- inventree-dev-server - inventree-dev-server
volumes: volumes:
# Ensure you specify the location of the 'src' directory at the end of this file # Ensure you specify the location of the 'src' directory at the end of this file
- src:/home/inventree - src:/home/inventree
env_file: environment:
# Environment variables required for the dev server are configured in dev-config.env - INVENTREE_DEBUG=True
- sqlite-config.env - INVENTREE_DB_ENGINE=sqlite
- INVENTREE_DB_NAME=/home/inventree/db.sqlite3
restart: unless-stopped restart: unless-stopped
volumes: volumes:
@ -59,4 +61,4 @@ volumes:
o: bind o: bind
# This directory specified where InvenTree source code is stored "outside" the docker containers # This directory specified where InvenTree source code is stored "outside" the docker containers
# By default, this directory is one level above the "docker" directory # By default, this directory is one level above the "docker" directory
device: ../ device: ${INVENTREE_EXT_VOLUME:-../}

View File

@ -1,119 +1,104 @@
version: "3.8" version: "3.8"
# Docker compose recipe for InvenTree # Docker compose recipe for InvenTree development server
# - Runs PostgreSQL as the database backend # - Runs PostgreSQL as the database backend
# - Runs Gunicorn as the InvenTree web server # - Uses built-in django webserver
# - Runs the InvenTree background worker process # - Runs the InvenTree background worker process
# - Runs nginx as a reverse proxy # - Serves media and static content directly from Django webserver
# --------------------------------- # IMPORANT NOTE:
# IMPORTANT - READ BEFORE STARTING! # The InvenTree development image does not clone source code from git.
# --------------------------------- # Instead, it runs from source code on your local machine.
# Before running, ensure that you change the "/path/to/data" directory, # The django server will auto-detect any code changes and reload the server.
# specified in the "volumes" section at the end of this file.
# This path determines where the InvenTree data will be stored! # If you have cloned the InvenTree git repo, and not made any changes to this file,
# # then the default setup in this file should work straight out of the box, without modification
#
# InvenTree Image Versions
# ------------------------
# By default, this docker-compose script targets the STABLE version of InvenTree,
# image: inventree/inventree:stable
#
# To run the LATEST (development) version of InvenTree, change the target image to:
# image: inventree/inventree:latest
#
# Alternatively, you could target a specific tagged release version with (for example):
# image: inventree/inventree:0.5.3
#
# NOTE: If you change the target image, ensure it is the same for the following containers:
# - inventree-server
# - inventree-worker
services: services:
# Database service # Database service
# Use PostgreSQL as the database backend # Use PostgreSQL as the database backend
# Note: this can be changed to a different backend, # Note: This can be changed to a different backend if required
# just make sure that you change the INVENTREE_DB_xxx vars below inventree-dev-db:
inventree-db: container_name: inventree-dev-db
container_name: inventree-db
image: postgres:13 image: postgres:13
ports: ports:
- 5432/tcp - ${INVENTREE_DB_PORT:-5432}/tcp
environment: environment:
- PGDATA=/var/lib/postgresql/data/pgdb - PGDATA=/var/lib/postgresql/data/dev/pgdb
# The pguser and pgpassword values must be the same in the other containers - POSTGRES_USER=${INVENTREE_DB_USER:?You must provide the 'INVENTREE_DB_USER' variable in the .env file}
# Ensure that these are correctly configured in your prod-config.env file - POSTGRES_PASSWORD=${INVENTREE_DB_PASSWORD:?You must provide the 'INVENTREE_DB_PASSWORD' variable in the .env file}
- POSTGRES_USER=pguser - POSTGRES_DB=${INVENTREE_DB_NAME:?You must provide the 'INVENTREE_DB_NAME' variable in the .env file}
- POSTGRES_PASSWORD=pgpassword
volumes: volumes:
# Map 'data' volume such that postgres database is stored externally # Map 'data' volume such that postgres database is stored externally
- data:/var/lib/postgresql/data/ - inventree_src:/var/lib/postgresql/data
restart: unless-stopped restart: unless-stopped
# InvenTree web server services # InvenTree web server services
# Uses gunicorn as the web server # Uses gunicorn as the web server
inventree-server: inventree-dev-server:
container_name: inventree-server container_name: inventree-dev-server
# If you wish to specify a particular InvenTree version, do so here
image: inventree/inventree:stable
expose:
- 8000
depends_on: depends_on:
- inventree-db - inventree-dev-db
build:
context: .
target: dev
# Cache the built image to be used by the inventree-dev-worker process
image: inventree-dev-image
ports:
# Expose web server on port 8000
- 8000:8000
# Note: If using the inventree-dev-proxy container (see below),
# comment out the "ports" directive (above) and uncomment the "expose" directive
#expose:
# - 8000
volumes: volumes:
# Data volume must map to /home/inventree/data # Ensure you specify the location of the 'src' directory at the end of this file
- data:/home/inventree/data - inventree_src:/home/inventree
env_file: env_file:
# Environment variables required for the production server are configured in prod-config.env - .env
- prod-config.env
restart: unless-stopped restart: unless-stopped
# Background worker process handles long-running or periodic tasks # Background worker process handles long-running or periodic tasks
inventree-worker: inventree-dev-worker:
container_name: inventree-worker container_name: inventree-dev-worker
# If you wish to specify a particular InvenTree version, do so here image: inventree-dev-image
image: inventree/inventree:stable
command: invoke worker command: invoke worker
depends_on: depends_on:
- inventree-db - inventree-dev-server
- inventree-server
volumes: volumes:
# Data volume must map to /home/inventree/data # Ensure you specify the location of the 'src' directory at the end of this file
- data:/home/inventree/data - inventree_src:/home/inventree
env_file: env_file:
# Environment variables required for the production server are configured in prod-config.env - .env
- prod-config.env
restart: unless-stopped restart: unless-stopped
# nginx acts as a reverse proxy ### Optional: Serve static and media files using nginx
# static files are served directly by nginx ### Uncomment the following lines to enable nginx proxy for testing
# media files are served by nginx, although authentication is redirected to inventree-server ### Note: If enabling the proxy, change "ports" to "expose" for the inventree-dev-server container (above)
# web requests are redirected to gunicorn #inventree-dev-proxy:
# NOTE: You will need to provide a working nginx.conf file! # container_name: inventree-dev-proxy
inventree-proxy: # image: nginx:stable
container_name: inventree-proxy # depends_on:
image: nginx:stable # - inventree-dev-server
depends_on: # ports:
- inventree-server # # Change "8000" to the port that you want InvenTree web server to be available on
ports: # - 8000:80
# Change "1337" to the port that you want InvenTree web server to be available on # volumes:
- 1337:80 # # Provide ./nginx.dev.conf file to the container
volumes: # # Refer to the provided example file as a starting point
# Provide ./nginx.conf file to the container # - ./nginx.dev.conf:/etc/nginx/conf.d/default.conf:ro
# Refer to the provided example file as a starting point # # nginx proxy needs access to static and media files
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro # - inventree_src:/var/www
# nginx proxy needs access to static and media files # restart: unless-stopped
- data:/var/www
restart: unless-stopped
volumes: volumes:
# NOTE: Change /path/to/data to a directory on your local machine
# Persistent data, stored external to the container(s) # Persistent data, stored external to the container(s)
data: inventree_src:
driver: local driver: local
driver_opts: driver_opts:
type: none type: none
o: bind o: bind
# This directory specified where InvenTree data are stored "outside" the docker containers # This directory specified where InvenTree source code is stored "outside" the docker containers
# Change this path to a local system path where you want InvenTree data stored # By default, this directory is one level above the "docker" directory
device: /path/to/data device: ${INVENTREE_EXT_VOLUME:-../}

View File

@ -33,7 +33,7 @@ if [[ -n "$INVENTREE_PY_ENV" ]]; then
source ${INVENTREE_PY_ENV}/bin/activate source ${INVENTREE_PY_ENV}/bin/activate
# Note: Python packages will have to be installed on first run # Note: Python packages will have to be installed on first run
# e.g docker-compose -f docker-compose.dev.yml run inventree-dev-server invoke install # e.g docker-compose run inventree-dev-server invoke update
fi fi
cd ${INVENTREE_HOME} cd ${INVENTREE_HOME}

View File

@ -4,24 +4,30 @@ server {
# Listen for connection on (internal) port 80 # Listen for connection on (internal) port 80
listen 80; listen 80;
location / { real_ip_header proxy_protocol;
# Change 'inventree-dev-server' to the name of the inventree server container,
# and '8000' to the INVENTREE_WEB_PORT (if not default) location / {
proxy_pass http://inventree-dev-server:8000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-By $server_addr:$server_port;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header CLIENT_IP $remote_addr;
proxy_pass_request_headers on;
proxy_redirect off; proxy_redirect off;
client_max_body_size 100M; client_max_body_size 100M;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off; proxy_buffering off;
proxy_request_buffering off; proxy_request_buffering off;
# Change 'inventree-dev-server' to the name of the inventree server container,
# and '8000' to the INVENTREE_WEB_PORT (if not default)
proxy_pass http://inventree-dev-server:8000;
} }
# Redirect any requests for static files # Redirect any requests for static files

View File

@ -1,19 +0,0 @@
# InvenTree environment variables for a production setup
# Note: If your production setup varies from the example, you may want to change these values
# Ensure debug is false for a production setup
INVENTREE_DEBUG=False
INVENTREE_LOG_LEVEL=WARNING
# Database configuration options
# Note: The example setup is for a PostgreSQL database (change as required)
INVENTREE_DB_ENGINE=postgresql
INVENTREE_DB_NAME=inventree
INVENTREE_DB_HOST=inventree-db
INVENTREE_DB_PORT=5432
INVENTREE_DB_USER=pguser
INVENTREE_DB_PASSWORD=pgpassword
# Enable plugins?
INVENTREE_PLUGINS_ENABLED=False

34
docker/production/.env Normal file
View File

@ -0,0 +1,34 @@
# InvenTree environment variables for a postgresql production setup
# Location of persistent database data (stored external to the docker containers)
# Note: You *must* un-comment this line, and point it to a path on your local machine
# e.g. Linux
#INVENTREE_EXT_VOLUME=/home/me/inventree-data
# e.g. Windows (docker desktop)
#INVENTREE_EXT_VOLUME=c:/Users/me/inventree-data
# Default web port for the InvenTree server
INVENTREE_WEB_PORT=1337
# Ensure debug is false for a production setup
INVENTREE_DEBUG=False
INVENTREE_LOG_LEVEL=WARNING
# Database configuration options
# Note: The example setup is for a PostgreSQL database
INVENTREE_DB_ENGINE=postgresql
INVENTREE_DB_NAME=inventree
INVENTREE_DB_HOST=inventree-db
INVENTREE_DB_PORT=5432
# Database credentials - These must be configured before running
# Uncomment the lines below, and change from the default values!
#INVENTREE_DB_USER=pguser
#INVENTREE_DB_PASSWORD=pgpassword
# Enable plugins?
INVENTREE_PLUGINS_ENABLED=False
COMPOSE_PROJECT_NAME=inventree-production

View File

@ -0,0 +1,124 @@
version: "3.8"
# Docker compose recipe for InvenTree production server
# - PostgreSQL as the database backend
# - gunicorn as the InvenTree web server
# - django-q as the InvenTree background worker process
# - nginx as a reverse proxy
# ---------------------
# READ BEFORE STARTING!
# ---------------------
# -----------------------------
# Setting environment variables
# -----------------------------
# Shared environment variables should be stored in the .env file
# Changes made to this file are reflected across all containers!
#
# IMPORTANT NOTE:
# You should not have to change *anything* within the docker-compose.yml file!
# Instead, make any changes in the .env file!
# The only *mandatory* change is to set the INVENTREE_EXT_VOLUME variable,
# which defines the directory (on your local machine) where persistent data are stored.
# ------------------------
# InvenTree Image Versions
# ------------------------
# By default, this docker-compose script targets the STABLE version of InvenTree,
# image: inventree/inventree:stable
#
# To run the LATEST (development) version of InvenTree, change the target image to:
# image: inventree/inventree:latest
#
# Alternatively, you could target a specific tagged release version with (for example):
# image: inventree/inventree:0.5.3
#
# NOTE: If you change the target image, ensure it is the same for the following containers:
# - inventree-server
# - inventree-worker
services:
# Database service
# Use PostgreSQL as the database backend
inventree-db:
container_name: inventree-db
image: postgres:13
ports:
- ${INVENTREE_DB_PORT:-5432}/tcp
environment:
- PGDATA=/var/lib/postgresql/data/pgdb
- POSTGRES_USER=${INVENTREE_DB_USER:?You must provide the 'INVENTREE_DB_USER' variable in the .env file}
- POSTGRES_PASSWORD=${INVENTREE_DB_PASSWORD:?You must provide the 'INVENTREE_DB_PASSWORD' variable in the .env file}
- POSTGRES_DB=${INVENTREE_DB_NAME:?You must provide the 'INVENTREE_DB_NAME' variable in the .env file}
volumes:
# Map 'data' volume such that postgres database is stored externally
- inventree_data:/var/lib/postgresql/data/
restart: unless-stopped
# InvenTree web server services
# Uses gunicorn as the web server
inventree-server:
container_name: inventree-server
# If you wish to specify a particular InvenTree version, do so here
image: inventree/inventree:stable
expose:
- 8000
depends_on:
- inventree-db
env_file:
- .env
volumes:
# Data volume must map to /home/inventree/data
- inventree_data:/home/inventree/data
restart: unless-stopped
# Background worker process handles long-running or periodic tasks
inventree-worker:
container_name: inventree-worker
# If you wish to specify a particular InvenTree version, do so here
image: inventree/inventree:stable
command: invoke worker
depends_on:
- inventree-db
- inventree-server
env_file:
- .env
volumes:
# Data volume must map to /home/inventree/data
- inventree_data:/home/inventree/data
restart: unless-stopped
# nginx acts as a reverse proxy
# static files are served directly by nginx
# media files are served by nginx, although authentication is redirected to inventree-server
# web requests are redirected to gunicorn
# NOTE: You will need to provide a working nginx.conf file!
inventree-proxy:
container_name: inventree-proxy
image: nginx:stable
depends_on:
- inventree-server
env_file:
- .env
ports:
# Default web port is 1337 (can be changed in the .env file)
- ${INVENTREE_WEB_PORT:-1337}:80
volumes:
# Provide nginx configuration file to the container
# Refer to the provided example file as a starting point
- ./nginx.prod.conf:/etc/nginx/conf.d/default.conf:ro
# nginx proxy needs access to static and media files
- inventree_data:/var/www
restart: unless-stopped
volumes:
# NOTE: Change /path/to/data to a directory on your local machine
# Persistent data, stored external to the container(s)
inventree_data:
driver: local
driver_opts:
type: none
o: bind
# This directory specified where InvenTree data are stored "outside" the docker containers
device: ${INVENTREE_EXT_VOLUME:?You must specify the 'INVENTREE_EXT_VOLUME' variable in the .env file!}

View File

@ -4,24 +4,29 @@ server {
# Listen for connection on (internal) port 80 # Listen for connection on (internal) port 80
listen 80; listen 80;
location / { real_ip_header proxy_protocol;
# Change 'inventree-server' to the name of the inventree server container,
# and '8000' to the INVENTREE_WEB_PORT (if not default) location / {
proxy_pass http://inventree-server:8000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-By $server_addr:$server_port;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header CLIENT_IP $remote_addr;
proxy_pass_request_headers on;
proxy_redirect off; proxy_redirect off;
client_max_body_size 100M; client_max_body_size 100M;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off; proxy_buffering off;
proxy_request_buffering off; proxy_request_buffering off;
# Change 'inventree-server' to the name of the inventree server container,
# and '8000' to the INVENTREE_WEB_PORT (if not default)
proxy_pass http://inventree-server:8000;
} }
# Redirect any requests for static files # Redirect any requests for static files

View File

@ -1,10 +0,0 @@
# InvenTree environment variables for a development setup
# Set DEBUG to False for a production environment!
INVENTREE_DEBUG=True
INVENTREE_DEBUG_LEVEL=INFO
# Database configuration options
# Note: The example setup is for a PostgreSQL database (change as required)
INVENTREE_DB_ENGINE=sqlite
INVENTREE_DB_NAME=/home/inventree/dev/inventree_db.sqlite3

View File

@ -1,5 +1,6 @@
# Please keep this list sorted # Please keep this list sorted
Django==3.2.12 # Django package Django==3.2.13 # Django package
bleach==4.1.0 # HTML santization
certifi # Certifi is (most likely) installed through one of the requirements above certifi # Certifi is (most likely) installed through one of the requirements above
coreapi==2.3.0 # API documentation coreapi==2.3.0 # API documentation
coverage==5.3 # Unit test coverage coverage==5.3 # Unit test coverage
@ -11,7 +12,7 @@ django-allauth-2fa==0.8 # MFA / 2FA
django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
django-cors-headers==3.2.0 # CORS headers extension for DRF django-cors-headers==3.2.0 # CORS headers extension for DRF
django-crispy-forms==1.11.2 # Form helpers django-crispy-forms==1.11.2 # Form helpers
django-debug-toolbar==2.2 # Debug / profiling toolbar django-debug-toolbar==3.2.4 # Debug / profiling toolbar
django-error-report==0.2.0 # Error report viewer for the admin interface django-error-report==0.2.0 # Error report viewer for the admin interface
django-filter==2.4.0 # Extended filtering options django-filter==2.4.0 # Extended filtering options
django-formtools==2.3 # Form wizard tools django-formtools==2.3 # Form wizard tools
@ -21,7 +22,7 @@ django-markdownify==0.8.0 # Markdown rendering
django-markdownx==3.0.1 # Markdown form fields django-markdownx==3.0.1 # Markdown form fields
django-money==1.1 # Django app for currency management django-money==1.1 # Django app for currency management
django-mptt==0.11.0 # Modified Preorder Tree Traversal django-mptt==0.11.0 # Modified Preorder Tree Traversal
django-redis>=5.0.0 django-redis>=5.0.0 # Redis integration
django-q==1.3.4 # Background task scheduling django-q==1.3.4 # Background task scheduling
django-sql-utils==0.5.0 # Advanced query annotation / aggregation django-sql-utils==0.5.0 # Advanced query annotation / aggregation
django-stdimage==5.1.1 # Advanced ImageField management django-stdimage==5.1.1 # Advanced ImageField management
@ -29,6 +30,7 @@ django-test-migrations==1.1.0 # Unit testing for database migrations
django-user-sessions==1.7.1 # user sessions in DB django-user-sessions==1.7.1 # user sessions in DB
django-weasyprint==1.0.1 # django weasyprint integration django-weasyprint==1.0.1 # django weasyprint integration
djangorestframework==3.12.4 # DRF framework djangorestframework==3.12.4 # DRF framework
django-xforwardedfor-middleware==2.0 # IP forwarding metadata
flake8==3.8.3 # PEP checking flake8==3.8.3 # PEP checking
gunicorn>=20.1.0 # Gunicorn web server gunicorn>=20.1.0 # Gunicorn web server
importlib_metadata # Backport for importlib.metadata importlib_metadata # Backport for importlib.metadata