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

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -210,6 +210,46 @@ class StockItemListTest(StockAPITestCase):
for item in response:
self.assertIsNone(item['serial'])
def test_filter_by_has_batch(self):
"""
Test the 'has_batch' filter, which tests if the stock item has been assigned a batch code
"""
with_batch = self.get_stock(has_batch=1)
without_batch = self.get_stock(has_batch=0)
n_stock_items = StockItem.objects.all().count()
# Total sum should equal the total count of stock items
self.assertEqual(n_stock_items, len(with_batch) + len(without_batch))
for item in with_batch:
self.assertFalse(item['batch'] in [None, ''])
for item in without_batch:
self.assertTrue(item['batch'] in [None, ''])
def test_filter_by_tracked(self):
"""
Test the 'tracked' filter.
This checks if the stock item has either a batch code *or* a serial number
"""
tracked = self.get_stock(tracked=True)
untracked = self.get_stock(tracked=False)
n_stock_items = StockItem.objects.all().count()
self.assertEqual(n_stock_items, len(tracked) + len(untracked))
blank = [None, '']
for item in tracked:
self.assertTrue(item['batch'] not in blank or item['serial'] not in blank)
for item in untracked:
self.assertTrue(item['batch'] in blank and item['serial'] in blank)
def test_filter_by_expired(self):
"""
Filter StockItem by expiry status

View File

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

View File

@ -24,6 +24,7 @@
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" icon="fa-link" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" icon="fa-sitemap" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %}
{% include "InvenTree/settings/setting.html" with key="PLUGIN_ON_STARTUP" %}
</tbody>
</table>
</div>
@ -76,6 +77,12 @@
{% endfor %}
{% endif %}
{% if plugin.is_sample %}
<a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar">
<span class='badge bg-info rounded-pill'>{% trans "code sample" %}</span>
</a>
{% endif %}
{% if plugin.website %}
<a href="{{ plugin.website }}"><span class="fas fa-globe"></span></a>
{% endif %}

View File

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

View File

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

View File

@ -48,6 +48,7 @@
<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 'script/jquery-ui/jquery-ui.min.css' %}">
<link rel="stylesheet" href="{% static 'easymde/easymde.min.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/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/randomColor.min.js' %}"></script>

View File

@ -798,17 +798,38 @@ function loadBomTable(table, options={}) {
});
cols.push({
field: 'sub_part_detail.stock',
field: 'available_stock',
title: '{% trans "Available" %}',
searchable: false,
sortable: true,
formatter: function(value, row) {
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
var text = value;
if (value == null || value <= 0) {
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock" %}</span>`;
// 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);
@ -902,8 +923,10 @@ function loadBomTable(table, options={}) {
formatter: function(value, row) {
var can_build = 0;
var available = row.available_stock + (row.available_substitute_stock || 0) + (row.available_variant_stock || 0);
if (row.quantity > 0) {
can_build = row.sub_part_detail.stock / row.quantity;
can_build = available / row.quantity;
}
return +can_build.toFixed(2);
@ -914,11 +937,11 @@ function loadBomTable(table, options={}) {
var cb_b = 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) {
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;

View File

@ -1421,9 +1421,41 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
sortable: true,
},
{
field: 'sub_part_detail.stock',
field: 'available_stock',
title: '{% trans "Available" %}',
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',

View File

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

View File

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

View File

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

View File

@ -293,6 +293,7 @@ function categoryFields() {
return {
parent: {
help_text: '{% trans "Parent part category" %}',
required: false,
},
name: {},
description: {},
@ -373,6 +374,9 @@ function duplicatePart(pk, options={}) {
// Override the "variant_of" field
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" %}', {
@ -668,7 +672,20 @@ function loadPartVariantTable(table, partId, options={}) {
field: 'in_stock',
title: '{% trans "Stock" %}',
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() {
return `{% trans "No ${human_name} information found" %}`;
},
queryParams: {part: options.part},
queryParams: {
part: options.part
},
url: options.url,
onLoadSuccess: function(tableData) {
if (linkedGraph) {
@ -2006,36 +2025,45 @@ function initPriceBreakSet(table, options) {
}
pb_new_btn.click(function() {
launchModalForm(pb_new_url,
{
success: reloadPriceBreakTable,
data: {
part: part_id,
}
}
);
constructForm(pb_new_url, {
fields: {
part: {
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() {
var pk = $(this).attr('pk');
launchModalForm(
`/part/${pb_url_slug}/${pk}/delete/`,
{
success: reloadPriceBreakTable
}
);
constructForm(`${pb_url}${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Price Break" %}',
onSuccess: reloadPriceBreakTable,
});
});
table.on('click', `.button-${pb_url_slug}-edit`, function() {
var pk = $(this).attr('pk');
launchModalForm(
`/part/${pb_url_slug}/${pk}/edit/`,
{
success: reloadPriceBreakTable
}
);
constructForm(`${pb_url}${pk}/`, {
fields: {
quantity: {},
price: {},
price_currency: {},
},
title: '{% trans "Edit Price Break" %}',
onSuccess: reloadPriceBreakTable,
});
});
}

View File

@ -107,6 +107,7 @@ function stockLocationFields(options={}) {
var fields = {
parent: {
help_text: '{% trans "Parent stock location" %}',
required: false,
},
name: {},
description: {},
@ -240,9 +241,11 @@ function stockItemFields(options={}) {
serial: {
icon: 'fa-hashtag',
},
batch: {
icon: 'fa-layer-group',
},
status: {},
expiry_date: {},
batch: {},
purchase_price: {
icon: 'fa-dollar-sign',
},
@ -963,6 +966,10 @@ function adjustStock(action, items, options={}) {
quantity = `#${item.serial}`;
}
if (item.batch) {
quantity += ` - <small>{% trans "Batch" %}: ${item.batch}</small>`;
}
var actionInput = '';
if (actionTitle != null) {
@ -1331,14 +1338,27 @@ function loadStockTestResultsTable(table, options) {
});
// Once the test template data are loaded, query for test results
var filters = loadTableFilters(filterKey);
var query_params = {
stock_item: options.stock_item,
user_detail: true,
attachment_detail: true,
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" %}',
{
stock_item: options.stock_item,
user_detail: true,
attachment_detail: true,
ordering: '-date',
},
query_params,
{
success: function(data) {
// Iterate through the returned test data
@ -2301,6 +2321,23 @@ function loadStockTrackingTable(table, options) {
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
cols.push({
field: 'date',
@ -2338,6 +2375,19 @@ function loadStockTrackingTable(table, options) {
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
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({
method: 'get',
queryParams: options.params,
queryParams: filters,
original: original,
columns: cols,
url: options.url,
});
@ -2626,7 +2659,8 @@ function installStockItem(stock_item_id, part_id, options={}) {
<ul>
<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 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>
</div>`;
@ -2652,7 +2686,7 @@ function installStockItem(stock_item_id, part_id, options={}) {
filters: {
part_detail: true,
in_stock: true,
serialized: true,
tracked: true,
},
adjustFilters: function(filters, opts) {
var part = getFormFieldValue('part', {}, opts);

View File

@ -234,10 +234,19 @@ function getAvailableTableFilters(tableKey) {
title: '{% trans "Stock status" %}',
description: '{% trans "Stock status" %}',
},
has_batch: {
title: '{% trans "Has batch code" %}',
type: 'bool',
},
batch: {
title: '{% trans "Batch" %}',
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: {
type: 'bool',
title: '{% trans "Has purchase price" %}',
@ -265,7 +274,16 @@ function getAvailableTableFilters(tableKey) {
// Filters for the 'stock test' table
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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,32 +21,34 @@ services:
build:
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
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
- sqlite-config.env
environment:
- INVENTREE_DEBUG=True
- INVENTREE_DB_ENGINE=sqlite
- INVENTREE_DB_NAME=/home/inventree/db.sqlite3
restart: unless-stopped
# Background worker process handles long-running or periodic tasks
inventree-dev-worker:
container_name: inventree-dev-worker
build:
context: .
target: dev
image: inventree-dev-image
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
- sqlite-config.env
environment:
- INVENTREE_DEBUG=True
- INVENTREE_DB_ENGINE=sqlite
- INVENTREE_DB_NAME=/home/inventree/db.sqlite3
restart: unless-stopped
volumes:
@ -59,4 +61,4 @@ volumes:
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: ../
device: ${INVENTREE_EXT_VOLUME:-../}

View File

@ -1,119 +1,104 @@
version: "3.8"
# Docker compose recipe for InvenTree
# Docker compose recipe for InvenTree development server
# - 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 nginx as a reverse proxy
# - Serves media and static content directly from Django webserver
# ---------------------------------
# IMPORTANT - READ BEFORE STARTING!
# ---------------------------------
# Before running, ensure that you change the "/path/to/data" directory,
# specified in the "volumes" section at the end of this file.
# This path determines where the InvenTree data will be 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
# IMPORANT NOTE:
# The InvenTree development image does not clone source code from git.
# Instead, it runs from source code on your local machine.
# The django server will auto-detect any code changes and reload the server.
# 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
services:
# Database service
# Use PostgreSQL as the database backend
# Note: this can be changed to a different backend,
# just make sure that you change the INVENTREE_DB_xxx vars below
inventree-db:
container_name: inventree-db
# 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
- ${INVENTREE_DB_PORT:-5432}/tcp
environment:
- PGDATA=/var/lib/postgresql/data/pgdb
# The pguser and pgpassword values must be the same in the other containers
# Ensure that these are correctly configured in your prod-config.env file
- POSTGRES_USER=pguser
- POSTGRES_PASSWORD=pgpassword
- PGDATA=/var/lib/postgresql/data/dev/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
- data:/var/lib/postgresql/data/
# Map 'data' volume such that postgres database is stored externally
- inventree_src:/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
inventree-dev-server:
container_name: inventree-dev-server
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:
# Data volume must map to /home/inventree/data
- data:/home/inventree/data
# Ensure you specify the location of the 'src' directory at the end of this file
- inventree_src:/home/inventree
env_file:
# Environment variables required for the production server are configured in prod-config.env
- prod-config.env
- .env
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
inventree-dev-worker:
container_name: inventree-dev-worker
image: inventree-dev-image
command: invoke worker
depends_on:
- inventree-db
- inventree-server
- inventree-dev-server
volumes:
# Data volume must map to /home/inventree/data
- data:/home/inventree/data
# Ensure you specify the location of the 'src' directory at the end of this file
- inventree_src:/home/inventree
env_file:
# Environment variables required for the production server are configured in prod-config.env
- prod-config.env
- .env
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
ports:
# Change "1337" to the port that you want InvenTree web server to be available on
- 1337:80
volumes:
# Provide ./nginx.conf file to the container
# Refer to the provided example file as a starting point
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
# nginx proxy needs access to static and media files
- data:/var/www
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.dev.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
# - inventree_src:/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)
data:
inventree_src:
driver: local
driver_opts:
type: none
o: bind
# This directory specified where InvenTree data are stored "outside" the docker containers
# Change this path to a local system path where you want InvenTree data stored
device: /path/to/data
# 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: ${INVENTREE_EXT_VOLUME:-../}

View File

@ -33,7 +33,7 @@ if [[ -n "$INVENTREE_PY_ENV" ]]; then
source ${INVENTREE_PY_ENV}/bin/activate
# 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
cd ${INVENTREE_HOME}

View File

@ -4,24 +4,30 @@ server {
# Listen for connection on (internal) port 80
listen 80;
location / {
# 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;
real_ip_header proxy_protocol;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
location / {
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;
client_max_body_size 100M;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_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

View File

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

34
docker/production/.env Normal file
View File

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

View File

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

View File

@ -4,24 +4,29 @@ server {
# Listen for connection on (internal) port 80
listen 80;
location / {
# 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;
real_ip_header proxy_protocol;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
location / {
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;
client_max_body_size 100M;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_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

View File

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

View File

@ -1,45 +1,47 @@
# Please keep this list sorted
Django==3.2.12 # Django package
certifi # Certifi is (most likely) installed through one of the requirements above
coreapi==2.3.0 # API documentation
coverage==5.3 # Unit test coverage
coveralls==2.1.2 # Coveralls linking (for Travis)
cryptography==3.4.8 # Cryptography support
django-admin-shell==0.1.2 # Python shell for the admin interface
django-allauth==0.45.0 # SSO for external providers via OpenID
django-allauth-2fa==0.8 # MFA / 2FA
django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
django-cors-headers==3.2.0 # CORS headers extension for DRF
django-crispy-forms==1.11.2 # Form helpers
django-debug-toolbar==2.2 # Debug / profiling toolbar
django-error-report==0.2.0 # Error report viewer for the admin interface
django-filter==2.4.0 # Extended filtering options
django-formtools==2.3 # Form wizard tools
django-import-export==2.5.0 # Data import / export for admin interface
django-maintenance-mode==0.16.1 # Shut down application while reloading etc.
django-markdownify==0.8.0 # Markdown rendering
django-markdownx==3.0.1 # Markdown form fields
django-money==1.1 # Django app for currency management
django-mptt==0.11.0 # Modified Preorder Tree Traversal
django-redis>=5.0.0
django-q==1.3.4 # Background task scheduling
django-sql-utils==0.5.0 # Advanced query annotation / aggregation
django-stdimage==5.1.1 # Advanced ImageField management
django-test-migrations==1.1.0 # Unit testing for database migrations
django-user-sessions==1.7.1 # user sessions in DB
django-weasyprint==1.0.1 # django weasyprint integration
djangorestframework==3.12.4 # DRF framework
flake8==3.8.3 # PEP checking
gunicorn>=20.1.0 # Gunicorn web server
importlib_metadata # Backport for importlib.metadata
inventree # Install the latest version of the InvenTree API python library
markdown==3.3.4 # Force particular version of markdown
pep8-naming==0.11.1 # PEP naming convention extension
pillow==9.0.1 # Image manipulation
py-moneyed==0.8.0 # Specific version requirement for py-moneyed
pygments==2.7.4 # Syntax highlighting
python-barcode[images]==0.13.1 # Barcode generator
qrcode[pil]==6.1 # QR code generator
rapidfuzz==0.7.6 # Fuzzy string matching
tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats
weasyprint==52.5 # PDF generation library (Note: in the future need to update to 53)
Django==3.2.13 # Django package
bleach==4.1.0 # HTML santization
certifi # Certifi is (most likely) installed through one of the requirements above
coreapi==2.3.0 # API documentation
coverage==5.3 # Unit test coverage
coveralls==2.1.2 # Coveralls linking (for Travis)
cryptography==3.4.8 # Cryptography support
django-admin-shell==0.1.2 # Python shell for the admin interface
django-allauth==0.45.0 # SSO for external providers via OpenID
django-allauth-2fa==0.8 # MFA / 2FA
django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
django-cors-headers==3.2.0 # CORS headers extension for DRF
django-crispy-forms==1.11.2 # Form helpers
django-debug-toolbar==3.2.4 # Debug / profiling toolbar
django-error-report==0.2.0 # Error report viewer for the admin interface
django-filter==2.4.0 # Extended filtering options
django-formtools==2.3 # Form wizard tools
django-import-export==2.5.0 # Data import / export for admin interface
django-maintenance-mode==0.16.1 # Shut down application while reloading etc.
django-markdownify==0.8.0 # Markdown rendering
django-markdownx==3.0.1 # Markdown form fields
django-money==1.1 # Django app for currency management
django-mptt==0.11.0 # Modified Preorder Tree Traversal
django-redis>=5.0.0 # Redis integration
django-q==1.3.4 # Background task scheduling
django-sql-utils==0.5.0 # Advanced query annotation / aggregation
django-stdimage==5.1.1 # Advanced ImageField management
django-test-migrations==1.1.0 # Unit testing for database migrations
django-user-sessions==1.7.1 # user sessions in DB
django-weasyprint==1.0.1 # django weasyprint integration
djangorestframework==3.12.4 # DRF framework
django-xforwardedfor-middleware==2.0 # IP forwarding metadata
flake8==3.8.3 # PEP checking
gunicorn>=20.1.0 # Gunicorn web server
importlib_metadata # Backport for importlib.metadata
inventree # Install the latest version of the InvenTree API python library
markdown==3.3.4 # Force particular version of markdown
pep8-naming==0.11.1 # PEP naming convention extension
pillow==9.0.1 # Image manipulation
py-moneyed==0.8.0 # Specific version requirement for py-moneyed
pygments==2.7.4 # Syntax highlighting
python-barcode[images]==0.13.1 # Barcode generator
qrcode[pil]==6.1 # QR code generator
rapidfuzz==0.7.6 # Fuzzy string matching
tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats
weasyprint==52.5 # PDF generation library (Note: in the future need to update to 53)