mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master' into order-parts-wizard
# Conflicts: # InvenTree/templates/js/translated/model_renderers.js
This commit is contained in:
commit
b8ca7fb092
159
InvenTree/InvenTree/api_version.py
Normal file
159
InvenTree/InvenTree/api_version.py
Normal 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 ¶meters=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
|
||||||
|
|
||||||
|
"""
|
@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
@ -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')),
|
||||||
|
7
InvenTree/InvenTree/static/easymde/easymde.min.css
vendored
Normal file
7
InvenTree/InvenTree/static/easymde/easymde.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
7
InvenTree/InvenTree/static/easymde/easymde.min.js
vendored
Normal file
7
InvenTree/InvenTree/static/easymde/easymde.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -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
@ -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",
|
||||||
|
@ -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'),
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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 ¶meters=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 """
|
||||||
|
@ -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)
|
||||||
|
@ -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() {
|
||||||
|
@ -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'),
|
||||||
|
@ -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() {
|
||||||
|
BIN
InvenTree/locale/cs/LC_MESSAGES/django.mo
Normal file
BIN
InvenTree/locale/cs/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
10114
InvenTree/locale/cs/LC_MESSAGES/django.po
Normal file
10114
InvenTree/locale/cs/LC_MESSAGES/django.po
Normal file
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
BIN
InvenTree/locale/fa/LC_MESSAGES/django.mo
Normal file
BIN
InvenTree/locale/fa/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
7845
InvenTree/locale/fa/LC_MESSAGES/django.po
Normal file
7845
InvenTree/locale/fa/LC_MESSAGES/django.po
Normal file
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
BIN
InvenTree/locale/pt_br/LC_MESSAGES/django.mo
Normal file
BIN
InvenTree/locale/pt_br/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
7859
InvenTree/locale/pt_br/LC_MESSAGES/django.po
Normal file
7859
InvenTree/locale/pt_br/LC_MESSAGES/django.po
Normal file
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
@ -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(
|
||||||
|
@ -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(
|
||||||
|
@ -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'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
"""
|
"""
|
||||||
|
@ -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',
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -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>
|
|
@ -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",
|
||||||
|
@ -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 %}
|
@ -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'),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -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 %}
|
||||||
|
|
||||||
|
@ -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 %}
|
||||||
|
@ -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 %}
|
||||||
|
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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):
|
||||||
|
|
||||||
|
@ -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)),
|
||||||
|
|
||||||
|
@ -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'
|
|
||||||
|
@ -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):
|
||||||
|
@ -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;
|
||||||
|
@ -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)
|
|
@ -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:
|
||||||
|
19
InvenTree/stock/migrations/0074_alter_stockitem_batch.py
Normal file
19
InvenTree/stock/migrations/0074_alter_stockitem_batch.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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.
|
||||||
|
@ -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',
|
||||||
|
@ -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
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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 %}
|
||||||
|
@ -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);
|
||||||
|
@ -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" %}
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
@ -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',
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -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>`;
|
||||||
|
|
||||||
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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
|
||||||
|
8
InvenTree/templates/notes_buttons.html
Normal file
8
InvenTree/templates/notes_buttons.html
Normal 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>
|
@ -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
|
@ -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
|
||||||
|
@ -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: ../
|
|
@ -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:-../}
|
||||||
|
@ -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:-../}
|
||||||
|
@ -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}
|
||||||
|
@ -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
|
||||||
|
@ -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
34
docker/production/.env
Normal 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
|
124
docker/production/docker-compose.yml
Normal file
124
docker/production/docker-compose.yml
Normal 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!}
|
@ -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
|
@ -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
|
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user