2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-30 04:26:44 +00:00

Merge branch 'master' of github.com:inventree/InvenTree into part_main_details

This commit is contained in:
eeintech 2021-07-26 13:59:15 -04:00
commit f61c768bbe
61 changed files with 3458 additions and 613 deletions

View File

@ -20,7 +20,6 @@ from djmoney.forms.fields import MoneyField
from djmoney.models.validators import MinMoneyValidator from djmoney.models.validators import MinMoneyValidator
import InvenTree.helpers import InvenTree.helpers
import common.settings
class InvenTreeURLFormField(FormURLField): class InvenTreeURLFormField(FormURLField):
@ -42,9 +41,11 @@ class InvenTreeURLField(models.URLField):
def money_kwargs(): def money_kwargs():
""" returns the database settings for MoneyFields """ """ returns the database settings for MoneyFields """
from common.settings import currency_code_mappings, currency_code_default
kwargs = {} kwargs = {}
kwargs['currency_choices'] = common.settings.currency_code_mappings() kwargs['currency_choices'] = currency_code_mappings()
kwargs['default_currency'] = common.settings.currency_code_default kwargs['default_currency'] = currency_code_default()
return kwargs return kwargs
@ -55,7 +56,7 @@ class InvenTreeModelMoneyField(ModelMoneyField):
def __init__(self, **kwargs): def __init__(self, **kwargs):
# detect if creating migration # detect if creating migration
if 'makemigrations' in sys.argv: if 'migrate' in sys.argv or 'makemigrations' in sys.argv:
# remove currency information for a clean migration # remove currency information for a clean migration
kwargs['default_currency'] = '' kwargs['default_currency'] = ''
kwargs['currency_choices'] = [] kwargs['currency_choices'] = []

View File

@ -631,13 +631,34 @@ def clean_decimal(number):
""" Clean-up decimal value """ """ Clean-up decimal value """
# Check if empty # Check if empty
if number is None or number == '': if number is None or number == '' or number == 0:
return Decimal(0) return Decimal(0)
# Check if decimal type # Convert to string and remove spaces
number = str(number).replace(' ', '')
# Guess what type of decimal and thousands separators are used
count_comma = number.count(',')
count_point = number.count('.')
if count_comma == 1:
# Comma is used as decimal separator
if count_point > 0:
# Points are used as thousands separators: remove them
number = number.replace('.', '')
# Replace decimal separator with point
number = number.replace(',', '.')
elif count_point == 1:
# Point is used as decimal separator
if count_comma > 0:
# Commas are used as thousands separators: remove them
number = number.replace(',', '')
# Convert to Decimal type
try: try:
clean_number = Decimal(number) clean_number = Decimal(number)
except InvalidOperation: except InvalidOperation:
clean_number = number # Number cannot be converted to Decimal (eg. a string containing letters)
return Decimal(0)
return clean_number.quantize(Decimal(1)) if clean_number == clean_number.to_integral() else clean_number.normalize() return clean_number.quantize(Decimal(1)) if clean_number == clean_number.to_integral() else clean_number.normalize()

View File

@ -32,6 +32,9 @@ class InvenTreeMetadata(SimpleMetadata):
def determine_metadata(self, request, view): def determine_metadata(self, request, view):
self.request = request
self.view = view
metadata = super().determine_metadata(request, view) metadata = super().determine_metadata(request, view)
user = request.user user = request.user
@ -136,6 +139,42 @@ class InvenTreeMetadata(SimpleMetadata):
except AttributeError: except AttributeError:
pass pass
# Try to extract 'instance' information
instance = None
# Extract extra information if an instance is available
if hasattr(serializer, 'instance'):
instance = serializer.instance
if instance is None:
try:
instance = self.view.get_object()
except:
pass
if instance is not None:
"""
If there is an instance associated with this API View,
introspect that instance to find any specific API info.
"""
if hasattr(instance, 'api_instance_filters'):
instance_filters = instance.api_instance_filters()
for field_name, field_filters in instance_filters.items():
if field_name not in serializer_info.keys():
# The field might be missing, but is added later on
# This function seems to get called multiple times?
continue
if 'instance_filters' not in serializer_info[field_name].keys():
serializer_info[field_name]['instance_filters'] = {}
for key, value in field_filters.items():
serializer_info[field_name]['instance_filters'][key] = value
return serializer_info return serializer_info
def get_field_info(self, field): def get_field_info(self, field):

View File

@ -93,6 +93,17 @@ class InvenTreeTree(MPTTModel):
parent: The item immediately above this one. An item with a null parent is a top-level item parent: The item immediately above this one. An item with a null parent is a top-level item
""" """
def api_instance_filters(self):
"""
Instance filters for InvenTreeTree models
"""
return {
'parent': {
'exclude_tree': self.pk,
}
}
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
try: try:

View File

@ -1037,3 +1037,10 @@ a.anchor {
height: 30px; height: 30px;
} }
.search-menu {
padding-top: 2rem;
}
.search-menu .ui-menu-item {
margin-top: 0.5rem;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@ -59,6 +59,11 @@
<h1>YOUR COMPONENTS:</h1> <h1>YOUR COMPONENTS:</h1>
<!-- Autocomplete -->
<h2 class="demoHeaders">Autocomplete</h2>
<div>
<input id="autocomplete" title="type &quot;a&quot;">
</div>
@ -248,6 +253,23 @@
<!-- Menu -->
<h2 class="demoHeaders">Menu</h2>
<ul style="width:100px;" id="menu">
<li><div>Item 1</div></li>
<li><div>Item 2</div></li>
<li><div>Item 3</div>
<ul>
<li><div>Item 3-1</div></li>
<li><div>Item 3-2</div></li>
<li><div>Item 3-3</div></li>
<li><div>Item 3-4</div></li>
<li><div>Item 3-5</div></li>
</ul>
</li>
<li><div>Item 4</div></li>
<li><div>Item 5</div></li>
</ul>
<!-- Highlight / Error --> <!-- Highlight / Error -->
@ -270,6 +292,33 @@
<script src="jquery-ui.js"></script> <script src="jquery-ui.js"></script>
<script> <script>
var availableTags = [
"ActionScript",
"AppleScript",
"Asp",
"BASIC",
"C",
"C++",
"Clojure",
"COBOL",
"ColdFusion",
"Erlang",
"Fortran",
"Groovy",
"Haskell",
"Java",
"JavaScript",
"Lisp",
"Perl",
"PHP",
"Python",
"Ruby",
"Scala",
"Scheme"
];
$( "#autocomplete" ).autocomplete({
source: availableTags
});
@ -280,6 +329,7 @@
$( "#menu" ).menu();

View File

@ -1,6 +1,6 @@
/*! jQuery UI - v1.12.1 - 2021-02-23 /*! jQuery UI - v1.12.1 - 2021-07-18
* http://jqueryui.com * http://jqueryui.com
* Includes: core.css, resizable.css, theme.css * Includes: core.css, resizable.css, autocomplete.css, menu.css, theme.css
* To view and modify this theme, visit http://jqueryui.com/themeroller/?scope=&folderName=base&cornerRadiusShadow=8px&offsetLeftShadow=0px&offsetTopShadow=0px&thicknessShadow=5px&opacityShadow=30&bgImgOpacityShadow=0&bgTextureShadow=flat&bgColorShadow=666666&opacityOverlay=30&bgImgOpacityOverlay=0&bgTextureOverlay=flat&bgColorOverlay=aaaaaa&iconColorError=cc0000&fcError=5f3f3f&borderColorError=f1a899&bgTextureError=flat&bgColorError=fddfdf&iconColorHighlight=777620&fcHighlight=777620&borderColorHighlight=dad55e&bgTextureHighlight=flat&bgColorHighlight=fffa90&iconColorActive=ffffff&fcActive=ffffff&borderColorActive=003eff&bgTextureActive=flat&bgColorActive=007fff&iconColorHover=555555&fcHover=2b2b2b&borderColorHover=cccccc&bgTextureHover=flat&bgColorHover=ededed&iconColorDefault=777777&fcDefault=454545&borderColorDefault=c5c5c5&bgTextureDefault=flat&bgColorDefault=f6f6f6&iconColorContent=444444&fcContent=333333&borderColorContent=dddddd&bgTextureContent=flat&bgColorContent=ffffff&iconColorHeader=444444&fcHeader=333333&borderColorHeader=dddddd&bgTextureHeader=flat&bgColorHeader=e9e9e9&cornerRadius=3px&fwDefault=normal&fsDefault=1em&ffDefault=Arial%2CHelvetica%2Csans-serif * To view and modify this theme, visit http://jqueryui.com/themeroller/?scope=&folderName=base&cornerRadiusShadow=8px&offsetLeftShadow=0px&offsetTopShadow=0px&thicknessShadow=5px&opacityShadow=30&bgImgOpacityShadow=0&bgTextureShadow=flat&bgColorShadow=666666&opacityOverlay=30&bgImgOpacityOverlay=0&bgTextureOverlay=flat&bgColorOverlay=aaaaaa&iconColorError=cc0000&fcError=5f3f3f&borderColorError=f1a899&bgTextureError=flat&bgColorError=fddfdf&iconColorHighlight=777620&fcHighlight=777620&borderColorHighlight=dad55e&bgTextureHighlight=flat&bgColorHighlight=fffa90&iconColorActive=ffffff&fcActive=ffffff&borderColorActive=003eff&bgTextureActive=flat&bgColorActive=007fff&iconColorHover=555555&fcHover=2b2b2b&borderColorHover=cccccc&bgTextureHover=flat&bgColorHover=ededed&iconColorDefault=777777&fcDefault=454545&borderColorDefault=c5c5c5&bgTextureDefault=flat&bgColorDefault=f6f6f6&iconColorContent=444444&fcContent=333333&borderColorContent=dddddd&bgTextureContent=flat&bgColorContent=ffffff&iconColorHeader=444444&fcHeader=333333&borderColorHeader=dddddd&bgTextureHeader=flat&bgColorHeader=e9e9e9&cornerRadius=3px&fwDefault=normal&fsDefault=1em&ffDefault=Arial%2CHelvetica%2Csans-serif
* Copyright jQuery Foundation and other contributors; Licensed MIT */ * Copyright jQuery Foundation and other contributors; Licensed MIT */
@ -160,6 +160,66 @@
right: -5px; right: -5px;
top: -5px; top: -5px;
} }
.ui-autocomplete {
position: absolute;
top: 0;
left: 0;
cursor: default;
}
.ui-menu {
list-style: none;
padding: 0;
margin: 0;
display: block;
outline: 0;
}
.ui-menu .ui-menu {
position: absolute;
}
.ui-menu .ui-menu-item {
margin: 0;
cursor: pointer;
/* support: IE10, see #8844 */
list-style-image: url("data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7");
}
.ui-menu .ui-menu-item-wrapper {
position: relative;
padding: 3px 1em 3px .4em;
}
.ui-menu .ui-menu-divider {
margin: 5px 0;
height: 0;
font-size: 0;
line-height: 0;
border-width: 1px 0 0 0;
}
.ui-menu .ui-state-focus,
.ui-menu .ui-state-active {
margin: -1px;
}
/* icon support */
.ui-menu-icons {
position: relative;
}
.ui-menu-icons .ui-menu-item-wrapper {
padding-left: 2em;
}
/* left-aligned */
.ui-menu .ui-icon {
position: absolute;
top: 0;
bottom: 0;
left: .2em;
margin: auto 0;
}
/* right-aligned */
.ui-menu .ui-menu-icon {
left: auto;
right: 0;
}
/* Component containers /* Component containers
----------------------------------*/ ----------------------------------*/

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -164,3 +164,63 @@
right: -5px; right: -5px;
top: -5px; top: -5px;
} }
.ui-autocomplete {
position: absolute;
top: 0;
left: 0;
cursor: default;
}
.ui-menu {
list-style: none;
padding: 0;
margin: 0;
display: block;
outline: 0;
}
.ui-menu .ui-menu {
position: absolute;
}
.ui-menu .ui-menu-item {
margin: 0;
cursor: pointer;
/* support: IE10, see #8844 */
list-style-image: url("data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7");
}
.ui-menu .ui-menu-item-wrapper {
position: relative;
padding: 3px 1em 3px .4em;
}
.ui-menu .ui-menu-divider {
margin: 5px 0;
height: 0;
font-size: 0;
line-height: 0;
border-width: 1px 0 0 0;
}
.ui-menu .ui-state-focus,
.ui-menu .ui-state-active {
margin: -1px;
}
/* icon support */
.ui-menu-icons {
position: relative;
}
.ui-menu-icons .ui-menu-item-wrapper {
padding-left: 2em;
}
/* left-aligned */
.ui-menu .ui-icon {
position: absolute;
top: 0;
bottom: 0;
left: .2em;
margin: auto 0;
}
/* right-aligned */
.ui-menu .ui-menu-icon {
left: auto;
right: 0;
}

View File

@ -1,5 +1,5 @@
/*! jQuery UI - v1.12.1 - 2021-02-23 /*! jQuery UI - v1.12.1 - 2021-07-18
* http://jqueryui.com * http://jqueryui.com
* Copyright jQuery Foundation and other contributors; Licensed MIT */ * Copyright jQuery Foundation and other contributors; Licensed MIT */
.ui-helper-hidden{display:none}.ui-helper-hidden-accessible{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.ui-helper-reset{margin:0;padding:0;border:0;outline:0;line-height:1.3;text-decoration:none;font-size:100%;list-style:none}.ui-helper-clearfix:before,.ui-helper-clearfix:after{content:"";display:table;border-collapse:collapse}.ui-helper-clearfix:after{clear:both}.ui-helper-zfix{width:100%;height:100%;top:0;left:0;position:absolute;opacity:0;filter:Alpha(Opacity=0)}.ui-front{z-index:100}.ui-state-disabled{cursor:default!important;pointer-events:none}.ui-icon{display:inline-block;vertical-align:middle;margin-top:-.25em;position:relative;text-indent:-99999px;overflow:hidden;background-repeat:no-repeat}.ui-widget-icon-block{left:50%;margin-left:-8px;display:block}.ui-widget-overlay{position:fixed;top:0;left:0;width:100%;height:100%}.ui-resizable{position:relative}.ui-resizable-handle{position:absolute;font-size:0.1px;display:block;-ms-touch-action:none;touch-action:none}.ui-resizable-disabled .ui-resizable-handle,.ui-resizable-autohide .ui-resizable-handle{display:none}.ui-resizable-n{cursor:n-resize;height:7px;width:100%;top:-5px;left:0}.ui-resizable-s{cursor:s-resize;height:7px;width:100%;bottom:-5px;left:0}.ui-resizable-e{cursor:e-resize;width:7px;right:-5px;top:0;height:100%}.ui-resizable-w{cursor:w-resize;width:7px;left:-5px;top:0;height:100%}.ui-resizable-se{cursor:se-resize;width:12px;height:12px;right:1px;bottom:1px}.ui-resizable-sw{cursor:sw-resize;width:9px;height:9px;left:-5px;bottom:-5px}.ui-resizable-nw{cursor:nw-resize;width:9px;height:9px;left:-5px;top:-5px}.ui-resizable-ne{cursor:ne-resize;width:9px;height:9px;right:-5px;top:-5px} .ui-helper-hidden{display:none}.ui-helper-hidden-accessible{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.ui-helper-reset{margin:0;padding:0;border:0;outline:0;line-height:1.3;text-decoration:none;font-size:100%;list-style:none}.ui-helper-clearfix:before,.ui-helper-clearfix:after{content:"";display:table;border-collapse:collapse}.ui-helper-clearfix:after{clear:both}.ui-helper-zfix{width:100%;height:100%;top:0;left:0;position:absolute;opacity:0;filter:Alpha(Opacity=0)}.ui-front{z-index:100}.ui-state-disabled{cursor:default!important;pointer-events:none}.ui-icon{display:inline-block;vertical-align:middle;margin-top:-.25em;position:relative;text-indent:-99999px;overflow:hidden;background-repeat:no-repeat}.ui-widget-icon-block{left:50%;margin-left:-8px;display:block}.ui-widget-overlay{position:fixed;top:0;left:0;width:100%;height:100%}.ui-resizable{position:relative}.ui-resizable-handle{position:absolute;font-size:0.1px;display:block;-ms-touch-action:none;touch-action:none}.ui-resizable-disabled .ui-resizable-handle,.ui-resizable-autohide .ui-resizable-handle{display:none}.ui-resizable-n{cursor:n-resize;height:7px;width:100%;top:-5px;left:0}.ui-resizable-s{cursor:s-resize;height:7px;width:100%;bottom:-5px;left:0}.ui-resizable-e{cursor:e-resize;width:7px;right:-5px;top:0;height:100%}.ui-resizable-w{cursor:w-resize;width:7px;left:-5px;top:0;height:100%}.ui-resizable-se{cursor:se-resize;width:12px;height:12px;right:1px;bottom:1px}.ui-resizable-sw{cursor:sw-resize;width:9px;height:9px;left:-5px;bottom:-5px}.ui-resizable-nw{cursor:nw-resize;width:9px;height:9px;left:-5px;top:-5px}.ui-resizable-ne{cursor:ne-resize;width:9px;height:9px;right:-5px;top:-5px}.ui-autocomplete{position:absolute;top:0;left:0;cursor:default}.ui-menu{list-style:none;padding:0;margin:0;display:block;outline:0}.ui-menu .ui-menu{position:absolute}.ui-menu .ui-menu-item{margin:0;cursor:pointer;list-style-image:url("data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7")}.ui-menu .ui-menu-item-wrapper{position:relative;padding:3px 1em 3px .4em}.ui-menu .ui-menu-divider{margin:5px 0;height:0;font-size:0;line-height:0;border-width:1px 0 0 0}.ui-menu .ui-state-focus,.ui-menu .ui-state-active{margin:-1px}.ui-menu-icons{position:relative}.ui-menu-icons .ui-menu-item-wrapper{padding-left:2em}.ui-menu .ui-icon{position:absolute;top:0;bottom:0;left:.2em;margin:auto 0}.ui-menu .ui-menu-icon{left:auto;right:0}

View File

@ -1,4 +1,4 @@
/*! jQuery UI - v1.12.1 - 2021-02-23 /*! jQuery UI - v1.12.1 - 2021-07-18
* http://jqueryui.com * http://jqueryui.com
* Copyright jQuery Foundation and other contributors; Licensed MIT */ * Copyright jQuery Foundation and other contributors; Licensed MIT */

View File

@ -0,0 +1,142 @@
"""
Validate that all URLs specified in template files are correct.
"""
from django.test import TestCase
from django.urls import reverse
import os
import re
from pathlib import Path
class URLTest(TestCase):
# Need fixture data in the database
fixtures = [
'settings',
'build',
'company',
'manufacturer_part',
'price_breaks',
'supplier_part',
'order',
'sales_order',
'bom',
'category',
'params',
'part_pricebreaks',
'part',
'test_templates',
'location',
'stock_tests',
'stock',
'users',
]
def find_files(self, suffix):
"""
Search for all files in the template directories,
which can have URLs rendered
"""
template_dirs = [
('build', 'templates'),
('common', 'templates'),
('company', 'templates'),
('label', 'templates'),
('order', 'templates'),
('part', 'templates'),
('report', 'templates'),
('stock', 'templates'),
('templates', ),
]
template_files = []
here = os.path.abspath(os.path.dirname(__file__))
tld = os.path.join(here, '..')
for directory in template_dirs:
template_dir = os.path.join(tld, *directory)
for path in Path(template_dir).rglob(suffix):
f = os.path.abspath(path)
if f not in template_files:
template_files.append(f)
return template_files
def find_urls(self, input_file):
"""
Search for all instances of {% url %} in supplied template file
"""
urls = []
pattern = "{% url ['\"]([^'\"]+)['\"]([^%]*)%}"
with open(input_file, 'r') as f:
data = f.read()
results = re.findall(pattern, data)
for result in results:
if len(result) == 2:
urls.append([
result[0].strip(),
result[1].strip()
])
elif len(result) == 1:
urls.append([
result[0].strip(),
''
])
return urls
def reverse_url(self, url_pair):
"""
Perform lookup on the URL
"""
url, pk = url_pair
# TODO: Handle reverse lookup of admin URLs!
if url.startswith("admin:"):
return
if pk:
# We will assume that there is at least one item in the database
reverse(url, kwargs={"pk": 1})
else:
reverse(url)
def check_file(self, f):
"""
Run URL checks for the provided file
"""
urls = self.find_urls(f)
for url in urls:
self.reverse_url(url)
def test_html_templates(self):
template_files = self.find_files("*.html")
for f in template_files:
self.check_file(f)
def test_js_templates(self):
template_files = self.find_files("*.js")
for f in template_files:
self.check_file(f)

View File

@ -43,7 +43,7 @@ from .views import CurrencySettingsView, CurrencyRefreshView
from .views import AppearanceSelectView, SettingCategorySelectView from .views import AppearanceSelectView, SettingCategorySelectView
from .views import DynamicJsView from .views import DynamicJsView
from common.views import SettingEdit from common.views import SettingEdit, UserSettingEdit
from .api import InfoView, NotFoundView from .api import InfoView, NotFoundView
from .api import ActionPluginView from .api import ActionPluginView
@ -79,6 +79,7 @@ apipatterns = [
settings_urls = [ settings_urls = [
url(r'^usersettings/', SettingsView.as_view(template_name='InvenTree/settings/user_settings.html'), name='settings-user-settings'),
url(r'^user/?', SettingsView.as_view(template_name='InvenTree/settings/user.html'), name='settings-user'), url(r'^user/?', SettingsView.as_view(template_name='InvenTree/settings/user.html'), name='settings-user'),
url(r'^appearance/?', AppearanceSelectView.as_view(), name='settings-appearance'), url(r'^appearance/?', AppearanceSelectView.as_view(), name='settings-appearance'),
url(r'^i18n/?', include('django.conf.urls.i18n')), url(r'^i18n/?', include('django.conf.urls.i18n')),
@ -94,6 +95,7 @@ settings_urls = [
url(r'^currencies/', CurrencySettingsView.as_view(), name='settings-currencies'), url(r'^currencies/', CurrencySettingsView.as_view(), name='settings-currencies'),
url(r'^currencies-refresh/', CurrencyRefreshView.as_view(), name='settings-currencies-refresh'), url(r'^currencies-refresh/', CurrencyRefreshView.as_view(), name='settings-currencies-refresh'),
url(r'^(?P<pk>\d+)/edit/user', UserSettingEdit.as_view(), name='user-setting-edit'),
url(r'^(?P<pk>\d+)/edit/', SettingEdit.as_view(), name='setting-edit'), url(r'^(?P<pk>\d+)/edit/', SettingEdit.as_view(), name='setting-edit'),
# Catch any other urls # Catch any other urls
@ -111,6 +113,7 @@ dynamic_javascript_urls = [
url(r'^company.js', DynamicJsView.as_view(template_name='js/company.js'), name='company.js'), url(r'^company.js', DynamicJsView.as_view(template_name='js/company.js'), name='company.js'),
url(r'^filters.js', DynamicJsView.as_view(template_name='js/filters.js'), name='filters.js'), url(r'^filters.js', DynamicJsView.as_view(template_name='js/filters.js'), name='filters.js'),
url(r'^forms.js', DynamicJsView.as_view(template_name='js/forms.js'), name='forms.js'), url(r'^forms.js', DynamicJsView.as_view(template_name='js/forms.js'), name='forms.js'),
url(r'^inventree.js', DynamicJsView.as_view(template_name='js/inventree.js'), name='inventree.js'),
url(r'^label.js', DynamicJsView.as_view(template_name='js/label.js'), name='label.js'), url(r'^label.js', DynamicJsView.as_view(template_name='js/label.js'), name='label.js'),
url(r'^model_renderers.js', DynamicJsView.as_view(template_name='js/model_renderers.js'), name='model_renderers.js'), url(r'^model_renderers.js', DynamicJsView.as_view(template_name='js/model_renderers.js'), name='model_renderers.js'),
url(r'^modals.js', DynamicJsView.as_view(template_name='js/modals.js'), name='modals.js'), url(r'^modals.js', DynamicJsView.as_view(template_name='js/modals.js'), name='modals.js'),

View File

@ -104,6 +104,21 @@ class BuildList(generics.ListCreateAPIView):
params = self.request.query_params params = self.request.query_params
# exclude parent tree
exclude_tree = params.get('exclude_tree', None)
if exclude_tree is not None:
try:
build = Build.objects.get(pk=exclude_tree)
queryset = queryset.exclude(
pk__in=[bld.pk for bld in build.get_descendants(include_self=True)]
)
except (ValueError, Build.DoesNotExist):
pass
# Filter by "parent" # Filter by "parent"
parent = params.get('parent', None) parent = params.get('parent', None)

View File

@ -96,6 +96,14 @@ class Build(MPTTModel):
def get_api_url(): def get_api_url():
return reverse('api-build-list') return reverse('api-build-list')
def api_instance_filters(self):
return {
'parent': {
'exclude_tree': self.pk,
}
}
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
try: try:

View File

@ -5,7 +5,7 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from .models import InvenTreeSetting from .models import InvenTreeSetting, InvenTreeUserSetting
class SettingsAdmin(ImportExportModelAdmin): class SettingsAdmin(ImportExportModelAdmin):
@ -13,4 +13,10 @@ class SettingsAdmin(ImportExportModelAdmin):
list_display = ('key', 'value') list_display = ('key', 'value')
class UserSettingsAdmin(ImportExportModelAdmin):
list_display = ('key', 'value', 'user', )
admin.site.register(InvenTreeSetting, SettingsAdmin) admin.site.register(InvenTreeSetting, SettingsAdmin)
admin.site.register(InvenTreeUserSetting, UserSettingsAdmin)

View File

@ -53,6 +53,7 @@ class FileManager:
ext = os.path.splitext(file.name)[-1].lower().replace('.', '') ext = os.path.splitext(file.name)[-1].lower().replace('.', '')
try:
if ext in ['csv', 'tsv', ]: if ext in ['csv', 'tsv', ]:
# These file formats need string decoding # These file formats need string decoding
raw_data = file.read().decode('utf-8') raw_data = file.read().decode('utf-8')
@ -64,6 +65,8 @@ class FileManager:
file.seek(0) file.seek(0)
else: else:
raise ValidationError(_(f'Unsupported file format: {ext.upper()}')) raise ValidationError(_(f'Unsupported file format: {ext.upper()}'))
except UnicodeEncodeError:
raise ValidationError(_('Error reading file (invalid encoding)'))
try: try:
cleaned_data = tablib.Dataset().load(raw_data, format=ext) cleaned_data = tablib.Dataset().load(raw_data, format=ext)

View File

@ -0,0 +1,33 @@
# Generated by Django 3.2.4 on 2021-07-22 21:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('common', '0010_migrate_currency_setting'),
]
operations = [
migrations.CreateModel(
name='InvenTreeUserSetting',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', models.CharField(blank=True, help_text='Settings value', max_length=200)),
('key', models.CharField(help_text='Settings key (must be unique - case insensitive', max_length=50)),
('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'InvenTree User Setting',
'verbose_name_plural': 'InvenTree User Settings',
},
),
migrations.AddConstraint(
model_name='inventreeusersetting',
constraint=models.UniqueConstraint(fields=('key', 'user'), name='unique key and user'),
),
]

View File

@ -11,6 +11,7 @@ import decimal
import math import math
from django.db import models, transaction from django.db import models, transaction
from django.contrib.auth.models import User
from django.db.utils import IntegrityError, OperationalError from django.db.utils import IntegrityError, OperationalError
from django.conf import settings from django.conf import settings
@ -18,8 +19,6 @@ from djmoney.settings import CURRENCY_CHOICES
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.exceptions import MissingRate
import common.settings
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.core.validators import MinValueValidator, URLValidator from django.core.validators import MinValueValidator, URLValidator
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -28,7 +27,397 @@ import InvenTree.helpers
import InvenTree.fields import InvenTree.fields
class InvenTreeSetting(models.Model): class BaseInvenTreeSetting(models.Model):
"""
An base InvenTreeSetting object is a key:value pair used for storing
single values (e.g. one-off settings values).
"""
GLOBAL_SETTINGS = {}
class Meta:
abstract = True
@classmethod
def get_setting_name(cls, key):
"""
Return the name of a particular setting.
If it does not exist, return an empty string.
"""
key = str(key).strip().upper()
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('name', '')
else:
return ''
@classmethod
def get_setting_description(cls, key):
"""
Return the description for a particular setting.
If it does not exist, return an empty string.
"""
key = str(key).strip().upper()
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('description', '')
else:
return ''
@classmethod
def get_setting_units(cls, key):
"""
Return the units for a particular setting.
If it does not exist, return an empty string.
"""
key = str(key).strip().upper()
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('units', '')
else:
return ''
@classmethod
def get_setting_validator(cls, key):
"""
Return the validator for a particular setting.
If it does not exist, return None
"""
key = str(key).strip().upper()
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('validator', None)
else:
return None
@classmethod
def get_setting_default(cls, key):
"""
Return the default value for a particular setting.
If it does not exist, return an empty string
"""
key = str(key).strip().upper()
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('default', '')
else:
return ''
@classmethod
def get_setting_choices(cls, key):
"""
Return the validator choices available for a particular setting.
"""
key = str(key).strip().upper()
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
choices = setting.get('choices', None)
else:
choices = None
"""
TODO:
if type(choices) is function:
# Evaluate the function (we expect it will return a list of tuples...)
return choices()
"""
return choices
@classmethod
def get_filters(cls, key, **kwargs):
return {'key__iexact': key}
@classmethod
def get_setting_object(cls, key, **kwargs):
"""
Return an InvenTreeSetting object matching the given key.
- Key is case-insensitive
- Returns None if no match is made
"""
key = str(key).strip().upper()
try:
setting = cls.objects.filter(**cls.get_filters(key, **kwargs)).first()
except (ValueError, cls.DoesNotExist):
setting = None
except (IntegrityError, OperationalError):
setting = None
# Setting does not exist! (Try to create it)
if not setting:
setting = cls(key=key, value=cls.get_setting_default(key), **kwargs)
try:
# Wrap this statement in "atomic", so it can be rolled back if it fails
with transaction.atomic():
setting.save()
except (IntegrityError, OperationalError):
# It might be the case that the database isn't created yet
pass
return setting
@classmethod
def get_setting_pk(cls, key):
"""
Return the primary-key value for a given setting.
If the setting does not exist, return None
"""
setting = cls.get_setting_object(cls)
if setting:
return setting.pk
else:
return None
@classmethod
def get_setting(cls, key, backup_value=None, **kwargs):
"""
Get the value of a particular setting.
If it does not exist, return the backup value (default = None)
"""
# If no backup value is specified, atttempt to retrieve a "default" value
if backup_value is None:
backup_value = cls.get_setting_default(key)
setting = cls.get_setting_object(key, **kwargs)
if setting:
value = setting.value
# If the particular setting is defined as a boolean, cast the value to a boolean
if setting.is_bool():
value = InvenTree.helpers.str2bool(value)
if setting.is_int():
try:
value = int(value)
except (ValueError, TypeError):
value = backup_value
else:
value = backup_value
return value
@classmethod
def set_setting(cls, key, value, change_user, create=True, **kwargs):
"""
Set the value of a particular setting.
If it does not exist, option to create it.
Args:
key: settings key
value: New value
change_user: User object (must be staff member to update a core setting)
create: If True, create a new setting if the specified key does not exist.
"""
if change_user is not None and not change_user.is_staff:
return
try:
setting = cls.objects.get(**cls.get_filters(key, **kwargs))
except cls.DoesNotExist:
if create:
setting = cls(key=key, **kwargs)
else:
return
# Enforce standard boolean representation
if setting.is_bool():
value = InvenTree.helpers.str2bool(value)
setting.value = str(value)
setting.save()
key = models.CharField(max_length=50, blank=False, unique=False, help_text=_('Settings key (must be unique - case insensitive'))
value = models.CharField(max_length=200, blank=True, unique=False, help_text=_('Settings value'))
@property
def name(self):
return self.__class__.get_setting_name(self.key)
@property
def default_value(self):
return self.__class__.get_setting_default(self.key)
@property
def description(self):
return self.__class__.get_setting_description(self.key)
@property
def units(self):
return self.__class__.get_setting_units(self.key)
def clean(self):
"""
If a validator (or multiple validators) are defined for a particular setting key,
run them against the 'value' field.
"""
super().clean()
validator = self.__class__.get_setting_validator(self.key)
if self.is_bool():
self.value = InvenTree.helpers.str2bool(self.value)
if self.is_int():
try:
self.value = int(self.value)
except (ValueError):
raise ValidationError(_('Must be an integer value'))
if validator is not None:
self.run_validator(validator)
def run_validator(self, validator):
"""
Run a validator against the 'value' field for this InvenTreeSetting object.
"""
if validator is None:
return
value = self.value
# Boolean validator
if self.is_bool():
# Value must "look like" a boolean value
if InvenTree.helpers.is_bool(value):
# Coerce into either "True" or "False"
value = InvenTree.helpers.str2bool(value)
else:
raise ValidationError({
'value': _('Value must be a boolean value')
})
# Integer validator
if self.is_int():
try:
# Coerce into an integer value
value = int(value)
except (ValueError, TypeError):
raise ValidationError({
'value': _('Value must be an integer value'),
})
# If a list of validators is supplied, iterate through each one
if type(validator) in [list, tuple]:
for v in validator:
self.run_validator(v)
if callable(validator):
# We can accept function validators with a single argument
validator(self.value)
def validate_unique(self, exclude=None, **kwargs):
""" Ensure that the key:value pair is unique.
In addition to the base validators, this ensures that the 'key'
is unique, using a case-insensitive comparison.
"""
super().validate_unique(exclude)
try:
setting = self.__class__.objects.exclude(id=self.id).filter(**self.get_filters(self.key, **kwargs))
if setting.exists():
raise ValidationError({'key': _('Key string must be unique')})
except self.DoesNotExist:
pass
def choices(self):
"""
Return the available choices for this setting (or None if no choices are defined)
"""
return self.__class__.get_setting_choices(self.key)
def is_bool(self):
"""
Check if this setting is required to be a boolean value
"""
validator = self.__class__.get_setting_validator(self.key)
if validator == bool:
return True
if type(validator) in [list, tuple]:
for v in validator:
if v == bool:
return True
def as_bool(self):
"""
Return the value of this setting converted to a boolean value.
Warning: Only use on values where is_bool evaluates to true!
"""
return InvenTree.helpers.str2bool(self.value)
def is_int(self):
"""
Check if the setting is required to be an integer value:
"""
validator = self.__class__.get_setting_validator(self.key)
if validator == int:
return True
if type(validator) in [list, tuple]:
for v in validator:
if v == int:
return True
return False
def as_int(self):
"""
Return the value of this setting converted to a boolean value.
If an error occurs, return the default value
"""
try:
value = int(self.value)
except (ValueError, TypeError):
value = self.default_value()
return value
class InvenTreeSetting(BaseInvenTreeSetting):
""" """
An InvenTreeSetting object is a key:value pair used for storing An InvenTreeSetting object is a key:value pair used for storing
single values (e.g. one-off settings values). single values (e.g. one-off settings values).
@ -279,6 +668,13 @@ class InvenTreeSetting(models.Model):
'validator': bool, 'validator': bool,
}, },
'SEARCH_PREVIEW_RESULTS': {
'name': _('Search Preview Results'),
'description': _('Number of results to show in search preview window'),
'default': 10,
'validator': [int, MinValueValidator(1)]
},
'STOCK_ENABLE_EXPIRY': { 'STOCK_ENABLE_EXPIRY': {
'name': _('Stock Expiry'), 'name': _('Stock Expiry'),
'description': _('Enable stock expiry functionality'), 'description': _('Enable stock expiry functionality'),
@ -357,379 +753,144 @@ class InvenTreeSetting(models.Model):
verbose_name = "InvenTree Setting" verbose_name = "InvenTree Setting"
verbose_name_plural = "InvenTree Settings" verbose_name_plural = "InvenTree Settings"
@classmethod key = models.CharField(
def get_setting_name(cls, key): max_length=50,
""" blank=False,
Return the name of a particular setting. unique=True,
help_text=_('Settings key (must be unique - case insensitive'),
)
If it does not exist, return an empty string.
class InvenTreeUserSetting(BaseInvenTreeSetting):
"""
An InvenTreeSetting object with a usercontext
""" """
key = str(key).strip().upper() GLOBAL_SETTINGS = {
'HOMEPAGE_PART_STARRED': {
'name': _('Show starred parts'),
'description': _('Show starred parts on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_PART_LATEST': {
'name': _('Show latest parts'),
'description': _('Show latest parts on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_BOM_VALIDATION': {
'name': _('Show unvalidated BOMs'),
'description': _('Show BOMs that await validation on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_STOCK_RECENT': {
'name': _('Show recent stock changes'),
'description': _('Show recently changed stock items on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_STOCK_LOW': {
'name': _('Show low stock'),
'description': _('Show low stock items on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_STOCK_DEPLETED': {
'name': _('Show depleted stock'),
'description': _('Show depleted stock items on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_STOCK_NEEDED': {
'name': _('Show needed stock'),
'description': _('Show stock items needed for builds on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_STOCK_EXPIRED': {
'name': _('Show expired stock'),
'description': _('Show expired stock items on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_STOCK_STALE': {
'name': _('Show stale stock'),
'description': _('Show stale stock items on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_BUILD_PENDING': {
'name': _('Show pending builds'),
'description': _('Show pending builds on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_BUILD_OVERDUE': {
'name': _('Show overdue builds'),
'description': _('Show overdue builds on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_PO_OUTSTANDING': {
'name': _('Show outstanding POs'),
'description': _('Show outstanding POs on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_PO_OVERDUE': {
'name': _('Show overdue POs'),
'description': _('Show overdue POs on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_SO_OUTSTANDING': {
'name': _('Show outstanding SOs'),
'description': _('Show outstanding SOs on the homepage'),
'default': True,
'validator': bool,
},
'HOMEPAGE_SO_OVERDUE': {
'name': _('Show overdue SOs'),
'description': _('Show overdue SOs on the homepage'),
'default': True,
'validator': bool,
},
}
if key in cls.GLOBAL_SETTINGS: class Meta:
setting = cls.GLOBAL_SETTINGS[key] verbose_name = "InvenTree User Setting"
return setting.get('name', '') verbose_name_plural = "InvenTree User Settings"
else: constraints = [
return '' models.UniqueConstraint(fields=['key', 'user'], name='unique key and user')
]
key = models.CharField(
max_length=50,
blank=False,
unique=False,
help_text=_('Settings key (must be unique - case insensitive'),
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
blank=True, null=True,
verbose_name=_('User'),
help_text=_('User'),
)
@classmethod @classmethod
def get_setting_description(cls, key): def get_setting_object(cls, key, user):
""" return super().get_setting_object(key, user=user)
Return the description for a particular setting.
If it does not exist, return an empty string.
"""
key = str(key).strip().upper()
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('description', '')
else:
return ''
@classmethod
def get_setting_units(cls, key):
"""
Return the units for a particular setting.
If it does not exist, return an empty string.
"""
key = str(key).strip().upper()
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('units', '')
else:
return ''
@classmethod
def get_setting_validator(cls, key):
"""
Return the validator for a particular setting.
If it does not exist, return None
"""
key = str(key).strip().upper()
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('validator', None)
else:
return None
@classmethod
def get_setting_default(cls, key):
"""
Return the default value for a particular setting.
If it does not exist, return an empty string
"""
key = str(key).strip().upper()
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('default', '')
else:
return ''
@classmethod
def get_setting_choices(cls, key):
"""
Return the validator choices available for a particular setting.
"""
key = str(key).strip().upper()
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
choices = setting.get('choices', None)
else:
choices = None
"""
TODO:
if type(choices) is function:
# Evaluate the function (we expect it will return a list of tuples...)
return choices()
"""
return choices
@classmethod
def get_setting_object(cls, key):
"""
Return an InvenTreeSetting object matching the given key.
- Key is case-insensitive
- Returns None if no match is made
"""
key = str(key).strip().upper()
try:
setting = InvenTreeSetting.objects.filter(key__iexact=key).first()
except (ValueError, InvenTreeSetting.DoesNotExist):
setting = None
except (IntegrityError, OperationalError):
setting = None
# Setting does not exist! (Try to create it)
if not setting:
setting = InvenTreeSetting(key=key, value=InvenTreeSetting.get_setting_default(key))
try:
# Wrap this statement in "atomic", so it can be rolled back if it fails
with transaction.atomic():
setting.save()
except (IntegrityError, OperationalError):
# It might be the case that the database isn't created yet
pass
return setting
@classmethod
def get_setting_pk(cls, key):
"""
Return the primary-key value for a given setting.
If the setting does not exist, return None
"""
setting = InvenTreeSetting.get_setting_object(cls)
if setting:
return setting.pk
else:
return None
@classmethod
def get_setting(cls, key, backup_value=None):
"""
Get the value of a particular setting.
If it does not exist, return the backup value (default = None)
"""
# If no backup value is specified, atttempt to retrieve a "default" value
if backup_value is None:
backup_value = cls.get_setting_default(key)
setting = InvenTreeSetting.get_setting_object(key)
if setting:
value = setting.value
# If the particular setting is defined as a boolean, cast the value to a boolean
if setting.is_bool():
value = InvenTree.helpers.str2bool(value)
if setting.is_int():
try:
value = int(value)
except (ValueError, TypeError):
value = backup_value
else:
value = backup_value
return value
@classmethod
def set_setting(cls, key, value, user, create=True):
"""
Set the value of a particular setting.
If it does not exist, option to create it.
Args:
key: settings key
value: New value
user: User object (must be staff member to update a core setting)
create: If True, create a new setting if the specified key does not exist.
"""
if user is not None and not user.is_staff:
return
try:
setting = InvenTreeSetting.objects.get(key__iexact=key)
except InvenTreeSetting.DoesNotExist:
if create:
setting = InvenTreeSetting(key=key)
else:
return
# Enforce standard boolean representation
if setting.is_bool():
value = InvenTree.helpers.str2bool(value)
setting.value = str(value)
setting.save()
key = models.CharField(max_length=50, blank=False, unique=True, help_text=_('Settings key (must be unique - case insensitive'))
value = models.CharField(max_length=200, blank=True, unique=False, help_text=_('Settings value'))
@property
def name(self):
return InvenTreeSetting.get_setting_name(self.key)
@property
def default_value(self):
return InvenTreeSetting.get_setting_default(self.key)
@property
def description(self):
return InvenTreeSetting.get_setting_description(self.key)
@property
def units(self):
return InvenTreeSetting.get_setting_units(self.key)
def clean(self):
"""
If a validator (or multiple validators) are defined for a particular setting key,
run them against the 'value' field.
"""
super().clean()
validator = InvenTreeSetting.get_setting_validator(self.key)
if self.is_bool():
self.value = InvenTree.helpers.str2bool(self.value)
if self.is_int():
try:
self.value = int(self.value)
except (ValueError):
raise ValidationError(_('Must be an integer value'))
if validator is not None:
self.run_validator(validator)
def run_validator(self, validator):
"""
Run a validator against the 'value' field for this InvenTreeSetting object.
"""
if validator is None:
return
value = self.value
# Boolean validator
if self.is_bool():
# Value must "look like" a boolean value
if InvenTree.helpers.is_bool(value):
# Coerce into either "True" or "False"
value = InvenTree.helpers.str2bool(value)
else:
raise ValidationError({
'value': _('Value must be a boolean value')
})
# Integer validator
if self.is_int():
try:
# Coerce into an integer value
value = int(value)
except (ValueError, TypeError):
raise ValidationError({
'value': _('Value must be an integer value'),
})
# If a list of validators is supplied, iterate through each one
if type(validator) in [list, tuple]:
for v in validator:
self.run_validator(v)
if callable(validator):
# We can accept function validators with a single argument
validator(self.value)
def validate_unique(self, exclude=None): def validate_unique(self, exclude=None):
""" Ensure that the key:value pair is unique. return super().validate_unique(exclude=exclude, user=self.user)
In addition to the base validators, this ensures that the 'key'
is unique, using a case-insensitive comparison.
"""
super().validate_unique(exclude) @classmethod
def get_filters(cls, key, **kwargs):
try: return {'key__iexact': key, 'user__id__iexact': kwargs['user'].id}
setting = InvenTreeSetting.objects.exclude(id=self.id).filter(key__iexact=self.key)
if setting.exists():
raise ValidationError({'key': _('Key string must be unique')})
except InvenTreeSetting.DoesNotExist:
pass
def choices(self):
"""
Return the available choices for this setting (or None if no choices are defined)
"""
return InvenTreeSetting.get_setting_choices(self.key)
def is_bool(self):
"""
Check if this setting is required to be a boolean value
"""
validator = InvenTreeSetting.get_setting_validator(self.key)
if validator == bool:
return True
if type(validator) in [list, tuple]:
for v in validator:
if v == bool:
return True
def as_bool(self):
"""
Return the value of this setting converted to a boolean value.
Warning: Only use on values where is_bool evaluates to true!
"""
return InvenTree.helpers.str2bool(self.value)
def is_int(self):
"""
Check if the setting is required to be an integer value:
"""
validator = InvenTreeSetting.get_setting_validator(self.key)
if validator == int:
return True
if type(validator) in [list, tuple]:
for v in validator:
if v == int:
return True
return False
def as_int(self):
"""
Return the value of this setting converted to a boolean value.
If an error occurs, return the default value
"""
try:
value = int(self.value)
except (ValueError, TypeError):
value = self.default_value()
return value
class PriceBreak(models.Model): class PriceBreak(models.Model):
@ -781,6 +942,7 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None, break
- If MOQ (minimum order quantity) is required, bump quantity - If MOQ (minimum order quantity) is required, bump quantity
- If order multiples are to be observed, then we need to calculate based on that, too - If order multiples are to be observed, then we need to calculate based on that, too
""" """
from common.settings import currency_code_default
if hasattr(instance, break_name): if hasattr(instance, break_name):
price_breaks = getattr(instance, break_name).all() price_breaks = getattr(instance, break_name).all()
@ -804,7 +966,7 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None, break
if currency is None: if currency is None:
# Default currency selection # Default currency selection
currency = common.settings.currency_code_default() currency = currency_code_default()
pb_min = None pb_min = None
for pb in price_breaks: for pb in price_breaks:

View File

@ -8,15 +8,19 @@ from __future__ import unicode_literals
from moneyed import CURRENCIES from moneyed import CURRENCIES
from django.conf import settings from django.conf import settings
import common.models
def currency_code_default(): def currency_code_default():
""" """
Returns the default currency code (or USD if not specified) Returns the default currency code (or USD if not specified)
""" """
from django.db.utils import ProgrammingError
from common.models import InvenTreeSetting
code = common.models.InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY') try:
code = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
except ProgrammingError:
# database is not initialized yet
code = ''
if code not in CURRENCIES: if code not in CURRENCIES:
code = 'USD' code = 'USD'
@ -42,5 +46,6 @@ def stock_expiry_enabled():
""" """
Returns True if the stock expiry feature is enabled Returns True if the stock expiry feature is enabled
""" """
from common.models import InvenTreeSetting
return common.models.InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY') return InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY')

View File

@ -45,8 +45,8 @@ class SettingEdit(AjaxUpdateView):
ctx['key'] = setting.key ctx['key'] = setting.key
ctx['value'] = setting.value ctx['value'] = setting.value
ctx['name'] = models.InvenTreeSetting.get_setting_name(setting.key) ctx['name'] = self.model.get_setting_name(setting.key)
ctx['description'] = models.InvenTreeSetting.get_setting_description(setting.key) ctx['description'] = self.model.get_setting_description(setting.key)
return ctx return ctx
@ -69,12 +69,12 @@ class SettingEdit(AjaxUpdateView):
self.object.value = str2bool(setting.value) self.object.value = str2bool(setting.value)
form.fields['value'].value = str2bool(setting.value) form.fields['value'].value = str2bool(setting.value)
name = models.InvenTreeSetting.get_setting_name(setting.key) name = self.model.get_setting_name(setting.key)
if name: if name:
form.fields['value'].label = name form.fields['value'].label = name
description = models.InvenTreeSetting.get_setting_description(setting.key) description = self.model.get_setting_description(setting.key)
if description: if description:
form.fields['value'].help_text = description form.fields['value'].help_text = description
@ -111,6 +111,18 @@ class SettingEdit(AjaxUpdateView):
form.add_error('value', _('Supplied value must be a boolean')) form.add_error('value', _('Supplied value must be a boolean'))
class UserSettingEdit(SettingEdit):
"""
View for editing an InvenTree key:value user settings object,
(or creating it if the key does not already exist)
"""
model = models.InvenTreeUserSetting
ajax_form_title = _('Change User Setting')
form_class = forms.SettingEditForm
ajax_template_name = "common/edit_setting.html"
class MultiStepFormView(SessionWizardView): class MultiStepFormView(SessionWizardView):
""" Setup basic methods of multi-step form """ Setup basic methods of multi-step form

View File

@ -24,14 +24,14 @@
{% comment "for later" %} {% comment "for later" %}
<li class='list-group-item {% if tab == "stock" %}active{% endif %}' title='{% trans "Manufacturer Part Stock" %}'> <li class='list-group-item {% if tab == "stock" %}active{% endif %}' title='{% trans "Manufacturer Part Stock" %}'>
<a href='{% url "manufacturer-part-stock" part.id %}'> <a href='#'>
<span class='fas fa-boxes sidebar-icon'></span> <span class='fas fa-boxes sidebar-icon'></span>
{% trans "Stock" %} {% trans "Stock" %}
</a> </a>
</li> </li>
<li class='list-group-item {% if tab == "orders" %}active{% endif %}' title='{% trans "Manufacturer Part Orders" %}'> <li class='list-group-item {% if tab == "orders" %}active{% endif %}' title='{% trans "Manufacturer Part Orders" %}'>
<a href='{% url "manufacturer-part-orders" part.id %}'> <a href='#'>
<span class='fas fa-shopping-cart sidebar-icon'></span> <span class='fas fa-shopping-cart sidebar-icon'></span>
{% trans "Orders" %} {% trans "Orders" %}
</a> </a>

View File

@ -30,11 +30,9 @@ company_urls = [
manufacturer_part_urls = [ manufacturer_part_urls = [
url(r'^(?P<pk>\d+)/', include([ url(r'^(?P<pk>\d+)/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part.html'), name='manufacturer-part-detail'),
url('^.*$', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part.html'), name='manufacturer-part-detail'),
])),
] ]
supplier_part_urls = [ supplier_part_urls = [
url('^.*$', views.SupplierPartDetail.as_view(template_name='company/supplier_part.html'), name='supplier-part-detail'), url(r'^(?P<pk>\d+)/', views.SupplierPartDetail.as_view(template_name='company/supplier_part.html'), name='supplier-part-detail'),
] ]

View File

@ -296,7 +296,9 @@ class StockItemLabel(LabelTemplate):
'uid': stock_item.uid, 'uid': stock_item.uid,
'qr_data': stock_item.format_barcode(brief=True), 'qr_data': stock_item.format_barcode(brief=True),
'qr_url': stock_item.format_barcode(url=True, request=request), 'qr_url': stock_item.format_barcode(url=True, request=request),
'tests': stock_item.testResultMap() 'tests': stock_item.testResultMap(),
'parameters': stock_item.part.parameters_map(),
} }
@ -398,4 +400,5 @@ class PartLabel(LabelTemplate):
'revision': part.revision, 'revision': part.revision,
'qr_data': part.format_barcode(brief=True), 'qr_data': part.format_barcode(brief=True),
'qr_url': part.format_barcode(url=True, request=request), 'qr_url': part.format_barcode(url=True, request=request),
'parameters': part.parameters_map(),
} }

View File

@ -84,9 +84,9 @@ class ReceivePurchaseOrderForm(HelperForm):
location = TreeNodeChoiceField( location = TreeNodeChoiceField(
queryset=StockLocation.objects.all(), queryset=StockLocation.objects.all(),
required=True, required=False,
label=_("Destination"), label=_("Destination"),
help_text=_("Receive parts to this location"), help_text=_("Set all received parts listed above to this location (if left blank, use \"Destination\" column value in above table)"),
) )
class Meta: class Meta:

View File

@ -1,13 +1,34 @@
{% extends "order/order_base.html" %} {% extends "order/purchase_order_detail.html" %}
{% load inventree_extras %} {% load inventree_extras %}
{% load i18n %} {% load i18n %}
{% load static %} {% load static %}
{% block heading %} {% block menubar %}
{% trans "Upload File for Purchase Order" %} <ul class='list-group'>
{{ wizard.form.media }} <li class='list-group-item'>
<a href='#' id='po-menu-toggle'>
<span class='menu-tab-icon fas fa-expand-arrows-alt'></span>
</a>
</li>
<li class='list-group-item' title='{% trans "Return To Order" %}'>
<a href='{% url "po-detail" order.id %}' id='select-upload-file' class='nav-toggle'>
<span class='fas fa-undo side-icon'></span>
{% trans "Return To Order" %}
</a>
</li>
</ul>
{% endblock %} {% endblock %}
{% block page_content %}
<div class='panel panel-default panel-inventree' id='panel-upload-file'>
<div class='panel-heading'>
{% block heading %}
<h4>{% trans "Upload File for Purchase Order" %}</h4>
{{ wizard.form.media }}
{% endblock %}
</div>
<div class='panel-content'>
{% block details %} {% block details %}
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %} {% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
@ -45,6 +66,9 @@
</div> </div>
{% endif %} {% endif %}
{% endblock details %} {% endblock details %}
</div>
{% endblock %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}

View File

@ -15,14 +15,6 @@
{% trans "Order Items" %} {% trans "Order Items" %}
</a> </a>
</li> </li>
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
<li class='list-group-item' title='{% trans "Upload File" %}'>
<a href='{% url "po-upload" order.id %}'>
<span class='fas fa-file-upload side-icon'></span>
{% trans "Upload File" %}
</a>
</li>
{% endif %}
<li class='list-group-item' title='{% trans "Received Stock Items" %}'> <li class='list-group-item' title='{% trans "Received Stock Items" %}'>
<a href='#' id='select-received-items' class='nav-toggle'> <a href='#' id='select-received-items' class='nav-toggle'>
<span class='fas fa-sign-in-alt side-icon'></span> <span class='fas fa-sign-in-alt side-icon'></span>

View File

@ -22,6 +22,9 @@
<button type='button' class='btn btn-primary' id='new-po-line'> <button type='button' class='btn btn-primary' id='new-po-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %} <span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
</button> </button>
<a class='btn btn-primary' href='{% url "po-upload" order.id %}' role='button'>
<span class='fas fa-file-upload side-icon'></span> {% trans "Upload File" %}
</a>
{% endif %} {% endif %}
</div> </div>

View File

@ -12,7 +12,7 @@
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
<label class='control-label'>{% trans "Parts" %}</label> <label class='control-label'>{% trans "Parts" %}</label>
<p class='help-block'>{% trans "Select parts to receive against this order" %}</p> <p class='help-block'>{% trans "Fill out number of parts received, the status and destination" %}</p>
<table class='table table-striped'> <table class='table table-striped'>
<tr> <tr>
@ -55,7 +55,14 @@
</div> </div>
</td> </td>
<td> <td>
{{ line.get_destination }} <div class='control-group'>
<select class='select' name='destination-{{ line.id }}'>
<option value="">----------</option>
{% for location in stock_locations %}
<option value="{{ location.pk }}" {% if location == line.get_destination %}selected="selected"{% endif %}>{{ location }}</option>
{% endfor %}
</select>
</div>
</td> </td>
<td> <td>
<button class='btn btn-default btn-remove' onClick="removeOrderRowFromOrderWizard()" id='del_item_{{ line.id }}' title='{% trans "Remove line" %}' type='button'> <button class='btn btn-default btn-remove' onClick="removeOrderRowFromOrderWizard()" id='del_item_{{ line.id }}' title='{% trans "Remove line" %}' type='button'>
@ -67,6 +74,8 @@
</table> </table>
{% crispy form %} {% crispy form %}
<div id='form-errors'>{{ form_errors }}</div>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -393,16 +393,7 @@ class PurchaseOrderUpload(FileManagementFormView):
p_val = row['data'][p_idx]['cell'] p_val = row['data'][p_idx]['cell']
if p_val: if p_val:
# Delete commas row['purchase_price'] = p_val
p_val = p_val.replace(',', '')
try:
# Attempt to extract a valid decimal value from the field
purchase_price = Decimal(p_val)
# Store the 'purchase_price' value
row['purchase_price'] = purchase_price
except (ValueError, InvalidOperation):
pass
# Check if there is a column corresponding to "reference" # Check if there is a column corresponding to "reference"
if r_idx >= 0: if r_idx >= 0:
@ -500,6 +491,7 @@ class PurchaseOrderReceive(AjaxUpdateView):
ctx = { ctx = {
'order': self.order, 'order': self.order,
'lines': self.lines, 'lines': self.lines,
'stock_locations': StockLocation.objects.all(),
} }
return ctx return ctx
@ -552,6 +544,7 @@ class PurchaseOrderReceive(AjaxUpdateView):
self.request = request self.request = request
self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk']) self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
errors = False
self.lines = [] self.lines = []
self.destination = None self.destination = None
@ -566,12 +559,6 @@ class PurchaseOrderReceive(AjaxUpdateView):
except (StockLocation.DoesNotExist, ValueError): except (StockLocation.DoesNotExist, ValueError):
pass pass
errors = False
if self.destination is None:
errors = True
msg = _("No destination set")
# Extract information on all submitted line items # Extract information on all submitted line items
for item in request.POST: for item in request.POST:
if item.startswith('line-'): if item.startswith('line-'):
@ -596,6 +583,21 @@ class PurchaseOrderReceive(AjaxUpdateView):
else: else:
line.status_code = StockStatus.OK line.status_code = StockStatus.OK
# Check the destination field
line.destination = None
if self.destination:
# If global destination is set, overwrite line value
line.destination = self.destination
else:
destination_key = f'destination-{pk}'
destination = request.POST.get(destination_key, None)
if destination:
try:
line.destination = StockLocation.objects.get(pk=destination)
except (StockLocation.DoesNotExist, ValueError):
pass
# Check that line matches the order # Check that line matches the order
if not line.order == self.order: if not line.order == self.order:
# TODO - Display a non-field error? # TODO - Display a non-field error?
@ -654,7 +656,7 @@ class PurchaseOrderReceive(AjaxUpdateView):
self.order.receive_line_item( self.order.receive_line_item(
line, line,
self.destination, line.destination,
line.receive_quantity, line.receive_quantity,
self.request.user, self.request.user,
status=line.status_code, status=line.status_code,

View File

@ -105,6 +105,20 @@ class CategoryList(generics.ListCreateAPIView):
except (ValueError, PartCategory.DoesNotExist): except (ValueError, PartCategory.DoesNotExist):
pass pass
# Exclude PartCategory tree
exclude_tree = params.get('exclude_tree', None)
if exclude_tree is not None:
try:
cat = PartCategory.objects.get(pk=exclude_tree)
queryset = queryset.exclude(
pk__in=[c.pk for c in cat.get_descendants(include_self=True)]
)
except (ValueError, PartCategory.DoesNotExist):
pass
return queryset return queryset
filter_backends = [ filter_backends = [
@ -361,7 +375,6 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.PartSerializer.prefetch_queryset(queryset)
queryset = part_serializers.PartSerializer.annotate_queryset(queryset) queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
return queryset return queryset
@ -619,8 +632,6 @@ class PartList(generics.ListCreateAPIView):
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.PartSerializer.prefetch_queryset(queryset)
queryset = part_serializers.PartSerializer.annotate_queryset(queryset) queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
return queryset return queryset
@ -633,10 +644,6 @@ class PartList(generics.ListCreateAPIView):
params = self.request.query_params params = self.request.query_params
# Annotate calculated data to the queryset
# (This will be used for further filtering)
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
queryset = super().filter_queryset(queryset) queryset = super().filter_queryset(queryset)
# Filter by "uses" query - Limit to parts which use the provided part # Filter by "uses" query - Limit to parts which use the provided part
@ -651,6 +658,20 @@ class PartList(generics.ListCreateAPIView):
except (ValueError, Part.DoesNotExist): except (ValueError, Part.DoesNotExist):
pass pass
# Exclude part variant tree?
exclude_tree = params.get('exclude_tree', None)
if exclude_tree is not None:
try:
top_level_part = Part.objects.get(pk=exclude_tree)
queryset = queryset.exclude(
pk__in=[prt.pk for prt in top_level_part.get_descendants(include_self=True)]
)
except (ValueError, Part.DoesNotExist):
pass
# Filter by 'ancestor'? # Filter by 'ancestor'?
ancestor = params.get('ancestor', None) ancestor = params.get('ancestor', None)

View File

@ -27,6 +27,8 @@ from markdownx.models import MarkdownxField
from django_cleanup import cleanup from django_cleanup import cleanup
from mptt.models import TreeForeignKey, MPTTModel from mptt.models import TreeForeignKey, MPTTModel
from mptt.exceptions import InvalidMove
from mptt.managers import TreeManager
from stdimage.models import StdImageField from stdimage.models import StdImageField
@ -284,6 +286,24 @@ def match_part_names(match, threshold=80, reverse=True, compare_length=False):
return matches return matches
class PartManager(TreeManager):
"""
Defines a custom object manager for the Part model.
The main purpose of this manager is to reduce the number of database hits,
as the Part model has a large number of ForeignKey fields!
"""
def get_queryset(self):
return super().get_queryset().prefetch_related(
'category',
'category__parent',
'stock_items',
'builds',
)
@cleanup.ignore @cleanup.ignore
class Part(MPTTModel): class Part(MPTTModel):
""" The Part object represents an abstract part, the 'concept' of an actual entity. """ The Part object represents an abstract part, the 'concept' of an actual entity.
@ -321,6 +341,8 @@ class Part(MPTTModel):
responsible: User who is responsible for this part (optional) responsible: User who is responsible for this part (optional)
""" """
objects = PartManager()
class Meta: class Meta:
verbose_name = _("Part") verbose_name = _("Part")
verbose_name_plural = _("Parts") verbose_name_plural = _("Parts")
@ -338,6 +360,17 @@ class Part(MPTTModel):
return reverse('api-part-list') return reverse('api-part-list')
def api_instance_filters(self):
"""
Return API query filters for limiting field results against this instance
"""
return {
'variant_of': {
'exclude_tree': self.pk,
}
}
def get_context_data(self, request, **kwargs): def get_context_data(self, request, **kwargs):
""" """
Return some useful context data about this part for template rendering Return some useful context data about this part for template rendering
@ -393,7 +426,12 @@ class Part(MPTTModel):
self.full_clean() self.full_clean()
try:
super().save(*args, **kwargs) super().save(*args, **kwargs)
except InvalidMove:
raise ValidationError({
'variant_of': _('Invalid choice for parent part'),
})
if add_category_templates: if add_category_templates:
# Get part category # Get part category
@ -1473,16 +1511,16 @@ class Part(MPTTModel):
return self.supplier_parts.count() return self.supplier_parts.count()
@property @property
def has_pricing_info(self): def has_pricing_info(self, internal=False):
""" Return true if there is pricing information for this part """ """ Return true if there is pricing information for this part """
return self.get_price_range() is not None return self.get_price_range(internal=internal) is not None
@property @property
def has_complete_bom_pricing(self): def has_complete_bom_pricing(self):
""" Return true if there is pricing information for each item in the BOM. """ """ Return true if there is pricing information for each item in the BOM. """
use_internal = common.models.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
for item in self.get_bom_items().all().select_related('sub_part'): for item in self.get_bom_items().all().select_related('sub_part'):
if not item.sub_part.has_pricing_info: if not item.sub_part.has_pricing_info(use_internal):
return False return False
return True return True
@ -1866,6 +1904,23 @@ class Part(MPTTModel):
return self.parameters.order_by('template__name') return self.parameters.order_by('template__name')
def parameters_map(self):
"""
Return a map (dict) of parameter values assocaited with this Part instance,
of the form:
{
"name_1": "value_1",
"name_2": "value_2",
}
"""
params = {}
for parameter in self.parameters.all():
params[parameter.template.name] = parameter.data
return params
@property @property
def has_variants(self): def has_variants(self):
""" Check if this Part object has variants underneath it. """ """ Check if this Part object has variants underneath it. """

View File

@ -215,25 +215,6 @@ class PartSerializer(InvenTreeModelSerializer):
if category_detail is not True: if category_detail is not True:
self.fields.pop('category_detail') self.fields.pop('category_detail')
@staticmethod
def prefetch_queryset(queryset):
"""
Prefetch related database tables,
to reduce database hits.
"""
return queryset.prefetch_related(
'category',
'category__parts',
'category__parent',
'stock_items',
'bom_items',
'builds',
'supplier_parts',
'supplier_parts__purchase_order_line_items',
'supplier_parts__purchase_order_line_items__order',
)
@staticmethod @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
""" """

View File

@ -18,7 +18,7 @@ from InvenTree import version, settings
import InvenTree.helpers import InvenTree.helpers
from common.models import InvenTreeSetting, ColorTheme from common.models import InvenTreeSetting, ColorTheme, InvenTreeUserSetting
from common.settings import currency_code_default from common.settings import currency_code_default
register = template.Library() register = template.Library()
@ -69,6 +69,12 @@ def add(x, y, *args, **kwargs):
return x + y return x + y
@register.simple_tag()
def to_list(*args):
""" Return the input arguments as list """
return args
@register.simple_tag() @register.simple_tag()
def part_allocation_count(build, part, *args, **kwargs): def part_allocation_count(build, part, *args, **kwargs):
""" Return the total number of <part> allocated to <build> """ """ Return the total number of <part> allocated to <build> """
@ -182,11 +188,12 @@ def setting_object(key, *args, **kwargs):
""" """
Return a setting object speciifed by the given key Return a setting object speciifed by the given key
(Or return None if the setting does not exist) (Or return None if the setting does not exist)
if a user-setting was requested return that
""" """
setting = InvenTreeSetting.get_setting_object(key) if 'user' in kwargs:
return InvenTreeUserSetting.get_setting_object(key, user=kwargs['user'])
return setting return InvenTreeSetting.get_setting_object(key)
@register.simple_tag() @register.simple_tag()
@ -195,6 +202,8 @@ def settings_value(key, *args, **kwargs):
Return a settings value specified by the given key Return a settings value specified by the given key
""" """
if 'user' in kwargs:
return InvenTreeUserSetting.get_setting(key, user=kwargs['user'])
return InvenTreeSetting.get_setting(key) return InvenTreeSetting.get_setting(key)

View File

@ -99,7 +99,7 @@ class CategoryTest(TestCase):
""" Test that the Category parameters are correctly fetched """ """ Test that the Category parameters are correctly fetched """
# Check number of SQL queries to iterate other parameters # Check number of SQL queries to iterate other parameters
with self.assertNumQueries(3): with self.assertNumQueries(7):
# Prefetch: 3 queries (parts, parameters and parameters_template) # Prefetch: 3 queries (parts, parameters and parameters_template)
fasteners = self.fasteners.prefetch_parts_parameters() fasteners = self.fasteners.prefetch_parts_parameters()
# Iterate through all parts and parameters # Iterate through all parts and parameters

View File

@ -356,6 +356,7 @@ class TestReport(ReportTemplateBase):
'stock_item': stock_item, 'stock_item': stock_item,
'serial': stock_item.serial, 'serial': stock_item.serial,
'part': stock_item.part, 'part': stock_item.part,
'parameters': stock_item.part.parameters_map(),
'results': stock_item.testResultMap(include_installed=self.include_installed), 'results': stock_item.testResultMap(include_installed=self.include_installed),
'result_list': stock_item.testResultList(include_installed=self.include_installed), 'result_list': stock_item.testResultList(include_installed=self.include_installed),
'installed_items': stock_item.get_installed_items(cascade=True), 'installed_items': stock_item.get_installed_items(cascade=True),

View File

@ -84,7 +84,6 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
queryset = StockItemSerializer.prefetch_queryset(queryset)
queryset = StockItemSerializer.annotate_queryset(queryset) queryset = StockItemSerializer.annotate_queryset(queryset)
return queryset return queryset
@ -344,6 +343,20 @@ class StockLocationList(generics.ListCreateAPIView):
except (ValueError, StockLocation.DoesNotExist): except (ValueError, StockLocation.DoesNotExist):
pass pass
# Exclude StockLocation tree
exclude_tree = params.get('exclude_tree', None)
if exclude_tree is not None:
try:
loc = StockLocation.objects.get(pk=exclude_tree)
queryset = queryset.exclude(
pk__in=[subloc.pk for subloc in loc.get_descendants(include_self=True)]
)
except (ValueError, StockLocation.DoesNotExist):
pass
return queryset return queryset
filter_backends = [ filter_backends = [
@ -637,7 +650,6 @@ class StockList(generics.ListCreateAPIView):
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
queryset = StockItemSerializer.prefetch_queryset(queryset)
queryset = StockItemSerializer.annotate_queryset(queryset) queryset = StockItemSerializer.annotate_queryset(queryset)
return queryset return queryset
@ -721,6 +733,20 @@ class StockList(generics.ListCreateAPIView):
if customer: if customer:
queryset = queryset.filter(customer=customer) queryset = queryset.filter(customer=customer)
# Exclude stock item tree
exclude_tree = params.get('exclude_tree', None)
if exclude_tree is not None:
try:
item = StockItem.objects.get(pk=exclude_tree)
queryset = queryset.exclude(
pk__in=[it.pk for it in item.get_descendants(include_self=True)]
)
except (ValueError, StockItem.DoesNotExist):
pass
# Filter by 'allocated' parts? # Filter by 'allocated' parts?
allocated = params.get('allocated', None) allocated = params.get('allocated', None)

View File

@ -23,6 +23,7 @@ from django.dispatch import receiver
from markdownx.models import MarkdownxField from markdownx.models import MarkdownxField
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
from mptt.managers import TreeManager
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -130,6 +131,31 @@ def before_delete_stock_location(sender, instance, using, **kwargs):
child.save() child.save()
class StockItemManager(TreeManager):
"""
Custom database manager for the StockItem class.
StockItem querysets will automatically prefetch related fields.
"""
def get_queryset(self):
return super().get_queryset().prefetch_related(
'belongs_to',
'build',
'customer',
'purchase_order',
'sales_order',
'supplier_part',
'supplier_part__supplier',
'allocations',
'sales_order_allocations',
'location',
'part',
'tracking_info'
)
class StockItem(MPTTModel): class StockItem(MPTTModel):
""" """
A StockItem object represents a quantity of physical instances of a part. A StockItem object represents a quantity of physical instances of a part.
@ -165,6 +191,17 @@ class StockItem(MPTTModel):
def get_api_url(): def get_api_url():
return reverse('api-stock-list') return reverse('api-stock-list')
def api_instance_filters(self):
"""
Custom API instance filters
"""
return {
'parent': {
'exclude_tree': self.pk,
}
}
# A Query filter which will be re-used in multiple places to determine if a StockItem is actually "in stock" # A Query filter which will be re-used in multiple places to determine if a StockItem is actually "in stock"
IN_STOCK_FILTER = Q( IN_STOCK_FILTER = Q(
quantity__gt=0, quantity__gt=0,

View File

@ -70,29 +70,6 @@ class StockItemSerializer(InvenTreeModelSerializer):
- Includes serialization for the item location - Includes serialization for the item location
""" """
@staticmethod
def prefetch_queryset(queryset):
"""
Prefetch related database tables,
to reduce database hits.
"""
return queryset.prefetch_related(
'belongs_to',
'build',
'customer',
'purchase_order',
'sales_order',
'supplier_part',
'supplier_part__supplier',
'supplier_part__manufacturer_part__manufacturer',
'allocations',
'sales_order_allocations',
'location',
'part',
'tracking_info',
)
@staticmethod @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
""" """

View File

@ -272,7 +272,7 @@
<tr> <tr>
<td><span class='fas fa-user-tie'></span></td> <td><span class='fas fa-user-tie'></span></td>
<td>{% trans "Customer" %}</td> <td>{% trans "Customer" %}</td>
<td><a href="{% url 'company-detail-assigned-stock' item.customer.id %}">{{ item.customer.name }}</a></td> <td><a href="{% url 'company-detail' item.customer.id %}?display=assigned-stock">{{ item.customer.name }}</a></td>
</tr> </tr>
{% endif %} {% endif %}
{% if item.belongs_to %} {% if item.belongs_to %}

View File

@ -93,13 +93,26 @@ function addHeaderAction(label, title, icon, options) {
}); });
} }
{% if roles.part.view %} {% settings_value 'HOMEPAGE_PART_STARRED' user=request.user as setting_part_starred %}
{% settings_value 'HOMEPAGE_PART_LATEST' user=request.user as setting_part_latest %}
{% settings_value 'HOMEPAGE_BOM_VALIDATION' user=request.user as setting_bom_validation %}
{% to_list setting_part_starred setting_part_latest setting_bom_validation as settings_list_part %}
{% if roles.part.view and True in settings_list_part %}
addHeaderTitle('{% trans "Parts" %}'); addHeaderTitle('{% trans "Parts" %}');
{% if setting_part_starred %}
addHeaderAction('starred-parts', '{% trans "Starred Parts" %}', 'fa-star'); addHeaderAction('starred-parts', '{% trans "Starred Parts" %}', 'fa-star');
loadSimplePartTable("#table-starred-parts", "{% url 'api-part-list' %}", {
params: {
"starred": true,
},
name: 'starred_parts',
});
{% endif %}
{% if setting_part_latest %}
addHeaderAction('latest-parts', '{% trans "Latest Parts" %}', 'fa-newspaper'); addHeaderAction('latest-parts', '{% trans "Latest Parts" %}', 'fa-newspaper');
addHeaderAction('bom-validation', '{% trans "BOM Waiting Validation" %}', 'fa-times-circle');
loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", { loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", {
params: { params: {
ordering: "-creation_date", ordering: "-creation_date",
@ -107,30 +120,37 @@ loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", {
}, },
name: 'latest_parts', name: 'latest_parts',
}); });
{% endif %}
loadSimplePartTable("#table-starred-parts", "{% url 'api-part-list' %}", { {% if setting_bom_validation %}
params: { addHeaderAction('bom-validation', '{% trans "BOM Waiting Validation" %}', 'fa-times-circle');
"starred": true,
},
name: 'starred_parts',
});
loadSimplePartTable("#table-bom-validation", "{% url 'api-part-list' %}", { loadSimplePartTable("#table-bom-validation", "{% url 'api-part-list' %}", {
params: { params: {
"bom_valid": false, "bom_valid": false,
}, },
name: 'bom_invalid_parts', name: 'bom_invalid_parts',
}); });
{% endif %}
{% endif %} {% endif %}
{% if roles.stock.view %} {% settings_value 'HOMEPAGE_STOCK_RECENT' user=request.user as setting_stock_recent %}
addHeaderTitle('{% trans "Stock" %}'); {% settings_value 'HOMEPAGE_STOCK_LOW' user=request.user as setting_stock_low %}
addHeaderAction('recently-updated-stock', '{% trans "Recently Updated" %}', 'fa-clock'); {% settings_value 'HOMEPAGE_STOCK_DEPLETED' user=request.user as setting_stock_depleted %}
addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-shopping-cart'); {% settings_value 'HOMEPAGE_STOCK_NEEDED' user=request.user as setting_stock_needed %}
addHeaderAction('depleted-stock', '{% trans "Depleted Stock" %}', 'fa-times'); {% settings_value "STOCK_ENABLE_EXPIRY" as expiry %}
addHeaderAction('stock-to-build', '{% trans "Required for Build Orders" %}', 'fa-bullhorn'); {% if expiry %}
{% settings_value 'HOMEPAGE_STOCK_EXPIRED' user=request.user as setting_stock_expired %}
{% settings_value 'HOMEPAGE_STOCK_STALE' user=request.user as setting_stock_stale %}
{% to_list setting_stock_recent setting_stock_low setting_stock_depleted setting_stock_needed setting_stock_expired setting_stock_stale as settings_list_stock %}
{% else %}
{% to_list setting_stock_recent setting_stock_low setting_stock_depleted setting_stock_needed as settings_list_stock %}
{% endif %}
{% if roles.stock.view and True in settings_list_stock %}
addHeaderTitle('{% trans "Stock" %}');
{% if setting_stock_recent %}
addHeaderAction('recently-updated-stock', '{% trans "Recently Updated" %}', 'fa-clock');
loadStockTable($('#table-recently-updated-stock'), { loadStockTable($('#table-recently-updated-stock'), {
params: { params: {
part_detail: true, part_detail: true,
@ -140,12 +160,43 @@ loadStockTable($('#table-recently-updated-stock'), {
name: 'recently-updated-stock', name: 'recently-updated-stock',
grouping: false, grouping: false,
}); });
{% endif %}
{% if setting_stock_low %}
addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-shopping-cart');
loadSimplePartTable("#table-low-stock", "{% url 'api-part-list' %}", {
params: {
low_stock: true,
},
name: "low_stock_parts",
});
{% endif %}
{% if setting_stock_depleted %}
addHeaderAction('depleted-stock', '{% trans "Depleted Stock" %}', 'fa-times');
loadSimplePartTable("#table-depleted-stock", "{% url 'api-part-list' %}", {
params: {
depleted_stock: true,
},
name: "depleted_stock_parts",
});
{% endif %}
{% if setting_stock_needed %}
addHeaderAction('stock-to-build', '{% trans "Required for Build Orders" %}', 'fa-bullhorn');
loadSimplePartTable("#table-stock-to-build", "{% url 'api-part-list' %}", {
params: {
stock_to_build: true,
},
name: "to_build_parts",
});
{% endif %}
{% settings_value "STOCK_ENABLE_EXPIRY" as expiry %}
{% if expiry %} {% if expiry %}
addHeaderAction('expired-stock', '{% trans "Expired Stock" %}', 'fa-calendar-times');
addHeaderAction('stale-stock', '{% trans "Stale Stock" %}', 'fa-stopwatch');
{% if setting_stock_expired %}
addHeaderAction('expired-stock', '{% trans "Expired Stock" %}', 'fa-calendar-times');
loadStockTable($("#table-expired-stock"), { loadStockTable($("#table-expired-stock"), {
params: { params: {
expired: true, expired: true,
@ -153,7 +204,10 @@ loadStockTable($("#table-expired-stock"), {
part_detail: true, part_detail: true,
}, },
}); });
{% endif %}
{% if setting_stock_stale %}
addHeaderAction('stale-stock', '{% trans "Stale Stock" %}', 'fa-stopwatch');
loadStockTable($("#table-stale-stock"), { loadStockTable($("#table-stale-stock"), {
params: { params: {
stale: true, stale: true,
@ -164,34 +218,18 @@ loadStockTable($("#table-stale-stock"), {
}); });
{% endif %} {% endif %}
loadSimplePartTable("#table-low-stock", "{% url 'api-part-list' %}", { {% endif %}
params: {
low_stock: true,
},
name: "low_stock_parts",
});
loadSimplePartTable("#table-depleted-stock", "{% url 'api-part-list' %}", {
params: {
depleted_stock: true,
},
name: "depleted_stock_parts",
});
loadSimplePartTable("#table-stock-to-build", "{% url 'api-part-list' %}", {
params: {
stock_to_build: true,
},
name: "to_build_parts",
});
{% endif %} {% endif %}
{% if roles.build.view %} {% settings_value 'HOMEPAGE_BUILD_PENDING' user=request.user as setting_build_pending %}
addHeaderTitle('{% trans "Build Orders" %}'); {% settings_value 'HOMEPAGE_BUILD_OVERDUE' user=request.user as setting_build_overdue %}
addHeaderAction('build-pending', '{% trans "Build Orders In Progress" %}', 'fa-cogs'); {% to_list setting_build_pending setting_build_overdue as settings_list_build %}
addHeaderAction('build-overdue', '{% trans "Overdue Build Orders" %}', 'fa-calendar-times');
{% if roles.build.view and True in settings_list_build %}
addHeaderTitle('{% trans "Build Orders" %}');
{% if setting_build_pending %}
addHeaderAction('build-pending', '{% trans "Build Orders In Progress" %}', 'fa-cogs');
loadBuildTable("#table-build-pending", { loadBuildTable("#table-build-pending", {
url: "{% url 'api-build-list' %}", url: "{% url 'api-build-list' %}",
params: { params: {
@ -199,7 +237,10 @@ loadBuildTable("#table-build-pending", {
}, },
disableFilters: true, disableFilters: true,
}); });
{% endif %}
{% if setting_build_overdue %}
addHeaderAction('build-overdue', '{% trans "Overdue Build Orders" %}', 'fa-calendar-times');
loadBuildTable("#table-build-overdue", { loadBuildTable("#table-build-overdue", {
url: "{% url 'api-build-list' %}", url: "{% url 'api-build-list' %}",
params: { params: {
@ -209,11 +250,17 @@ loadBuildTable("#table-build-overdue", {
}); });
{% endif %} {% endif %}
{% if roles.purchase_order.view %} {% endif %}
addHeaderTitle('{% trans "Purchase Orders" %}');
addHeaderAction('po-outstanding', '{% trans "Outstanding Purchase Orders" %}', 'fa-sign-in-alt');
addHeaderAction('po-overdue', '{% trans "Overdue Purchase Orders" %}', 'fa-calendar-times');
{% settings_value 'HOMEPAGE_PO_OUTSTANDING' user=request.user as setting_po_outstanding %}
{% settings_value 'HOMEPAGE_PO_OVERDUE' user=request.user as setting_po_overdue %}
{% to_list setting_po_outstanding setting_po_overdue as settings_list_po %}
{% if roles.purchase_order.view and True in settings_list_po %}
addHeaderTitle('{% trans "Purchase Orders" %}');
{% if setting_po_outstanding %}
addHeaderAction('po-outstanding', '{% trans "Outstanding Purchase Orders" %}', 'fa-sign-in-alt');
loadPurchaseOrderTable("#table-po-outstanding", { loadPurchaseOrderTable("#table-po-outstanding", {
url: "{% url 'api-po-list' %}", url: "{% url 'api-po-list' %}",
params: { params: {
@ -221,7 +268,10 @@ loadPurchaseOrderTable("#table-po-outstanding", {
outstanding: true, outstanding: true,
} }
}); });
{% endif %}
{% if setting_po_overdue %}
addHeaderAction('po-overdue', '{% trans "Overdue Purchase Orders" %}', 'fa-calendar-times');
loadPurchaseOrderTable("#table-po-overdue", { loadPurchaseOrderTable("#table-po-overdue", {
url: "{% url 'api-po-list' %}", url: "{% url 'api-po-list' %}",
params: { params: {
@ -229,14 +279,19 @@ loadPurchaseOrderTable("#table-po-overdue", {
overdue: true, overdue: true,
} }
}); });
{% endif %}
{% endif %} {% endif %}
{% if roles.sales_order.view %} {% settings_value 'HOMEPAGE_SO_OUTSTANDING' user=request.user as setting_so_outstanding %}
addHeaderTitle('{% trans "Sales Orders" %}'); {% settings_value 'HOMEPAGE_SO_OVERDUE' user=request.user as setting_so_overdue %}
addHeaderAction('so-outstanding', '{% trans "Outstanding Sales Orders" %}', 'fa-sign-out-alt'); {% to_list setting_so_outstanding setting_so_overdue as settings_list_so %}
addHeaderAction('so-overdue', '{% trans "Overdue Sales Orders" %}', 'fa-calendar-times');
{% if roles.sales_order.view and True in settings_list_so %}
addHeaderTitle('{% trans "Sales Orders" %}');
{% if setting_so_outstanding %}
addHeaderAction('so-outstanding', '{% trans "Outstanding Sales Orders" %}', 'fa-sign-out-alt');
loadSalesOrderTable("#table-so-outstanding", { loadSalesOrderTable("#table-so-outstanding", {
url: "{% url 'api-so-list' %}", url: "{% url 'api-so-list' %}",
params: { params: {
@ -244,7 +299,10 @@ loadSalesOrderTable("#table-so-outstanding", {
outstanding: true, outstanding: true,
}, },
}); });
{% endif %}
{% if setting_so_overdue %}
addHeaderAction('so-overdue', '{% trans "Overdue Sales Orders" %}', 'fa-calendar-times');
loadSalesOrderTable("#table-so-overdue", { loadSalesOrderTable("#table-so-overdue", {
url: "{% url 'api-so-list' %}", url: "{% url 'api-so-list' %}",
params: { params: {
@ -252,6 +310,7 @@ loadSalesOrderTable("#table-so-overdue", {
customer_detail: true, customer_detail: true,
} }
}); });
{% endif %}
{% endif %} {% endif %}

View File

@ -62,6 +62,4 @@
</div> </div>
</form> </form>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -31,4 +31,11 @@
</tbody> </tbody>
</table> </table>
<h4>{% trans "Search Settings" %}</h4>
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="SEARCH_PREVIEW_RESULTS" icon="fa-search" %}
</tbody>
</table>
{% endblock %} {% endblock %}

View File

@ -1,7 +1,12 @@
{% load inventree_extras %} {% load inventree_extras %}
{% load i18n %} {% load i18n %}
{% if user_setting %}
{% setting_object key user=request.user as setting %}
{% else %}
{% setting_object key as setting %} {% setting_object key as setting %}
{% endif %}
<tr> <tr>
<td> <td>
{% if icon %} {% if icon %}
@ -28,7 +33,7 @@
</td> </td>
<td> <td>
<div class='btn-group float-right'> <div class='btn-group float-right'>
<button class='btn btn-default btn-glyph btn-edit-setting' pk='{{ setting.pk }}' setting='{{ key }}' title='{% trans "Edit setting" %}'> <button class='btn btn-default btn-glyph btn-edit-setting' pk='{{ setting.pk }}' setting='{{ key }}' title='{% trans "Edit setting" %}' {% if user_setting %}user='{{request.user.id}}'{% endif %}>
<span class='fas fa-edit icon-green'></span> <span class='fas fa-edit icon-green'></span>
</button> </button>
</div> </div>

View File

@ -45,9 +45,14 @@
$('table').find('.btn-edit-setting').click(function() { $('table').find('.btn-edit-setting').click(function() {
var setting = $(this).attr('setting'); var setting = $(this).attr('setting');
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
var url = `/settings/${pk}/edit/`;
if ($(this).attr('user')){
url += `user/`;
}
launchModalForm( launchModalForm(
`/settings/${pk}/edit/`, url,
{ {
reload: true, reload: true,
} }

View File

@ -8,6 +8,9 @@
<li{% ifequal tab 'theme' %} class='active'{% endifequal %}> <li{% ifequal tab 'theme' %} class='active'{% endifequal %}>
<a href="{% url 'settings-appearance' %}"><span class='fas fa-fill'></span> {% trans "Appearance" %}</a> <a href="{% url 'settings-appearance' %}"><span class='fas fa-fill'></span> {% trans "Appearance" %}</a>
</li> </li>
<li{% ifequal tab 'user_settings' %} class='active'{% endifequal %}>
<a href="{% url 'settings-user-settings' %}"><span class='fas fa-cog'></span> {% trans "User Settings" %}</a>
</li>
</ul> </ul>
{% if user.is_staff %} {% if user.is_staff %}
<h4><span class='fas fa-cogs'></span> {% trans "InvenTree Settings" %}</h4> <h4><span class='fas fa-cogs'></span> {% trans "InvenTree Settings" %}</h4>

View File

@ -0,0 +1,41 @@
{% extends "InvenTree/settings/settings.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block tabs %}
{% include "InvenTree/settings/tabs.html" with tab='user_settings' %}
{% endblock %}
{% block subtitle %}
{% trans "User Settings" %}
{% endblock %}
{% block settings %}
<div class='row'>
<table class='table table-striped table-condensed'>
{% include "InvenTree/settings/header.html" %}
<tbody>
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_STARRED" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PART_LATEST" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_BOM_VALIDATION" user_setting=True %}
<tr><td colspan='5'></td></tr>
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_STOCK_RECENT" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_STOCK_LOW" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_STOCK_DEPLETED" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_STOCK_NEEDED" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_STOCK_EXPIRED" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_STOCK_STALE" user_setting=True %}
<tr><td colspan='5'></td></tr>
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_BUILD_PENDING" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_BUILD_OVERDUE" user_setting=True %}
<tr><td colspan='5'></td></tr>
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PO_OUTSTANDING" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_PO_OVERDUE" user_setting=True %}
<tr><td colspan='5'></td></tr>
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_SO_OUTSTANDING" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="HOMEPAGE_SO_OVERDUE" user_setting=True %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -142,11 +142,11 @@
<script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script> <script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script>
<!-- general InvenTree --> <!-- general InvenTree -->
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/sidenav.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/sidenav.js' %}"></script>
<!-- translated --> <!-- translated -->
<script type='text/javascript' src="{% i18n_static 'inventree.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'api.js' %}"></script> <script type='text/javascript' src="{% i18n_static 'api.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'attachment.js' %}"></script> <script type='text/javascript' src="{% i18n_static 'attachment.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'barcode.js' %}"></script> <script type='text/javascript' src="{% i18n_static 'barcode.js' %}"></script>

View File

@ -350,6 +350,12 @@ function constructFormBody(fields, options) {
for(field in fields) { for(field in fields) {
fields[field].name = field; fields[field].name = field;
// If any "instance_filters" are defined for the endpoint, copy them across (overwrite)
if (fields[field].instance_filters) {
fields[field].filters = Object.assign(fields[field].filters || {}, fields[field].instance_filters);
}
var field_options = displayed_fields[field]; var field_options = displayed_fields[field];
// Copy custom options across to the fields object // Copy custom options across to the fields object

View File

@ -1,3 +1,5 @@
{% load inventree_extras %}
function attachClipboard(selector, containerselector, textElement) { function attachClipboard(selector, containerselector, textElement) {
// set container // set container
if (containerselector){ if (containerselector){
@ -13,7 +15,8 @@ function attachClipboard(selector, containerselector, textElement) {
} }
} else { } else {
text = function(trigger) { text = function(trigger) {
var content = trigger.parentElement.parentElement.textContent;return content.trim(); var content = trigger.parentElement.parentElement.textContent;
return content.trim();
} }
} }
@ -80,6 +83,45 @@ function inventreeDocReady() {
attachClipboard('.clip-btn'); attachClipboard('.clip-btn');
attachClipboard('.clip-btn', 'modal-about'); // modals attachClipboard('.clip-btn', 'modal-about'); // modals
attachClipboard('.clip-btn-version', 'modal-about', 'about-copy-text'); // version-text attachClipboard('.clip-btn-version', 'modal-about', 'about-copy-text'); // version-text
// Add autocomplete to the search-bar
$("#search-bar" ).autocomplete({
source: function (request, response) {
$.ajax({
url: '/api/part/',
data: {
search: request.term,
limit: {% settings_value 'SEARCH_PREVIEW_RESULTS' %},
offset: 0
},
success: function (data) {
var transformed = $.map(data.results, function (el) {
return {
label: el.name,
id: el.pk,
thumbnail: el.thumbnail
};
});
response(transformed);
},
error: function () {
response([]);
}
});
},
create: function () {
$(this).data('ui-autocomplete')._renderItem = function (ul, item) {
return $('<li>')
.append('<span>' + imageHoverIcon(item.thumbnail) + item.label + '</span>')
.appendTo(ul);
};
},
select: function( event, ui ) {
window.location = '/part/' + ui.item.id + '/';
},
minLength: 2,
classes: {'ui-autocomplete': 'dropdown-menu search-menu'},
});
} }
function isFileTransfer(transfer) { function isFileTransfer(transfer) {

View File

@ -3,7 +3,7 @@
<form class="navbar-form navbar-left" action="{% url 'search' %}" method='post'> <form class="navbar-form navbar-left" action="{% url 'search' %}" method='post'>
{% csrf_token %} {% csrf_token %}
<div class="form-group"> <div class="form-group">
<input type="text" name='search' class="form-control" placeholder="{% trans 'Search' %}"{% if query_text %} value="{{ query }}"{% endif %}> <input type="text" name='search' class="form-control" id="search-bar" placeholder="{% trans 'Search' %}"{% if query_text %} value="{{ query }}"{% endif %}>
</div> </div>
<button type="submit" id='search-submit' class="btn btn-default" title='{% trans "Search" %}'> <button type="submit" id='search-submit' class="btn btn-default" title='{% trans "Search" %}'>
<span class='fas fa-search'></span> <span class='fas fa-search'></span>

View File

@ -141,6 +141,7 @@ class RuleSet(models.Model):
# Models which currently do not require permissions # Models which currently do not require permissions
'common_colortheme', 'common_colortheme',
'common_inventreesetting', 'common_inventreesetting',
'common_inventreeusersetting',
'company_contact', 'company_contact',
'users_owner', 'users_owner',

View File

@ -34,15 +34,6 @@ InvenTree is supported by a [companion mobile app](https://inventree.readthedocs
# Translation # Translation
![de translation](https://img.shields.io/badge/dynamic/json?color=blue&label=de&style=flat&query=%24.progress.0.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-14720186-452300.json)
![es-ES translation](https://img.shields.io/badge/dynamic/json?color=blue&label=es-ES&style=flat&query=%24.progress.1.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-14720186-452300.json)
![fr translation](https://img.shields.io/badge/dynamic/json?color=blue&label=fr&style=flat&query=%24.progress.3.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-14720186-452300.json)
![it translation](https://img.shields.io/badge/dynamic/json?color=blue&label=it&style=flat&query=%24.progress.4.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-14720186-452300.json)
![pl translation](https://img.shields.io/badge/dynamic/json?color=blue&label=pl&style=flat&query=%24.progress.5.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-14720186-452300.json)
![ru translation](https://img.shields.io/badge/dynamic/json?color=blue&label=ru&style=flat&query=%24.progress.6.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-14720186-452300.json)
![tr translation](https://img.shields.io/badge/dynamic/json?color=blue&label=tr&style=flat&query=%24.progress.6.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-14720186-452300.json)
![zh-CN translation](https://img.shields.io/badge/dynamic/json?color=blue&label=zh-CN&style=flat&query=%24.progress.7.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-14720186-452300.json)
Native language translation of the InvenTree web application is [community contributed via crowdin](https://crowdin.com/project/inventree). **Contributions are welcomed and encouraged**. Native language translation of the InvenTree web application is [community contributed via crowdin](https://crowdin.com/project/inventree). **Contributions are welcomed and encouraged**.
To contribute to the translation effort, navigate to the [InvenTree crowdin project](https://crowdin.com/project/inventree), create a free account, and start making translations suggestions for your language of choice! To contribute to the translation effort, navigate to the [InvenTree crowdin project](https://crowdin.com/project/inventree), create a free account, and start making translations suggestions for your language of choice!