Merge branch 'master' of github.com:inventree/InvenTree into part_main_details
@ -20,7 +20,6 @@ from djmoney.forms.fields import MoneyField
|
||||
from djmoney.models.validators import MinMoneyValidator
|
||||
|
||||
import InvenTree.helpers
|
||||
import common.settings
|
||||
|
||||
|
||||
class InvenTreeURLFormField(FormURLField):
|
||||
@ -42,9 +41,11 @@ class InvenTreeURLField(models.URLField):
|
||||
|
||||
def money_kwargs():
|
||||
""" returns the database settings for MoneyFields """
|
||||
from common.settings import currency_code_mappings, currency_code_default
|
||||
|
||||
kwargs = {}
|
||||
kwargs['currency_choices'] = common.settings.currency_code_mappings()
|
||||
kwargs['default_currency'] = common.settings.currency_code_default
|
||||
kwargs['currency_choices'] = currency_code_mappings()
|
||||
kwargs['default_currency'] = currency_code_default()
|
||||
return kwargs
|
||||
|
||||
|
||||
@ -55,7 +56,7 @@ class InvenTreeModelMoneyField(ModelMoneyField):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# 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
|
||||
kwargs['default_currency'] = ''
|
||||
kwargs['currency_choices'] = []
|
||||
|
@ -631,13 +631,34 @@ def clean_decimal(number):
|
||||
""" Clean-up decimal value """
|
||||
|
||||
# Check if empty
|
||||
if number is None or number == '':
|
||||
if number is None or number == '' or number == 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:
|
||||
clean_number = Decimal(number)
|
||||
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()
|
||||
|
@ -32,6 +32,9 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
|
||||
def determine_metadata(self, request, view):
|
||||
|
||||
self.request = request
|
||||
self.view = view
|
||||
|
||||
metadata = super().determine_metadata(request, view)
|
||||
|
||||
user = request.user
|
||||
@ -136,6 +139,42 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
except AttributeError:
|
||||
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
|
||||
|
||||
def get_field_info(self, field):
|
||||
|
@ -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
|
||||
"""
|
||||
|
||||
def api_instance_filters(self):
|
||||
"""
|
||||
Instance filters for InvenTreeTree models
|
||||
"""
|
||||
|
||||
return {
|
||||
'parent': {
|
||||
'exclude_tree': self.pk,
|
||||
}
|
||||
}
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
try:
|
||||
|
@ -1037,3 +1037,10 @@ a.anchor {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.search-menu {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.search-menu .ui-menu-item {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
@ -59,6 +59,11 @@
|
||||
<h1>YOUR COMPONENTS:</h1>
|
||||
|
||||
|
||||
<!-- Autocomplete -->
|
||||
<h2 class="demoHeaders">Autocomplete</h2>
|
||||
<div>
|
||||
<input id="autocomplete" title="type "a"">
|
||||
</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 -->
|
||||
@ -270,6 +292,33 @@
|
||||
<script src="jquery-ui.js"></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();
|
||||
|
||||
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
/*! jQuery UI - v1.12.1 - 2021-02-23
|
||||
/*! jQuery UI - v1.12.1 - 2021-07-18
|
||||
* 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
|
||||
* Copyright jQuery Foundation and other contributors; Licensed MIT */
|
||||
|
||||
@ -160,6 +160,66 @@
|
||||
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;
|
||||
/* 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
|
||||
----------------------------------*/
|
||||
|
1915
InvenTree/InvenTree/static/script/jquery-ui/jquery-ui.js
vendored
@ -164,3 +164,63 @@
|
||||
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;
|
||||
/* 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;
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
/*! jQuery UI - v1.12.1 - 2021-02-23
|
||||
/*! jQuery UI - v1.12.1 - 2021-07-18
|
||||
* http://jqueryui.com
|
||||
* 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}
|
@ -1,4 +1,4 @@
|
||||
/*! jQuery UI - v1.12.1 - 2021-02-23
|
||||
/*! jQuery UI - v1.12.1 - 2021-07-18
|
||||
* http://jqueryui.com
|
||||
* Copyright jQuery Foundation and other contributors; Licensed MIT */
|
||||
|
||||
|
142
InvenTree/InvenTree/test_urls.py
Normal 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)
|
@ -43,7 +43,7 @@ from .views import CurrencySettingsView, CurrencyRefreshView
|
||||
from .views import AppearanceSelectView, SettingCategorySelectView
|
||||
from .views import DynamicJsView
|
||||
|
||||
from common.views import SettingEdit
|
||||
from common.views import SettingEdit, UserSettingEdit
|
||||
|
||||
from .api import InfoView, NotFoundView
|
||||
from .api import ActionPluginView
|
||||
@ -79,6 +79,7 @@ apipatterns = [
|
||||
|
||||
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'^appearance/?', AppearanceSelectView.as_view(), name='settings-appearance'),
|
||||
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-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'),
|
||||
|
||||
# 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'^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'^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'^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'),
|
||||
|
@ -104,6 +104,21 @@ class BuildList(generics.ListCreateAPIView):
|
||||
|
||||
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"
|
||||
parent = params.get('parent', None)
|
||||
|
||||
|
@ -96,6 +96,14 @@ class Build(MPTTModel):
|
||||
def get_api_url():
|
||||
return reverse('api-build-list')
|
||||
|
||||
def api_instance_filters(self):
|
||||
|
||||
return {
|
||||
'parent': {
|
||||
'exclude_tree': self.pk,
|
||||
}
|
||||
}
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
try:
|
||||
|
@ -5,7 +5,7 @@ from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
from .models import InvenTreeSetting
|
||||
from .models import InvenTreeSetting, InvenTreeUserSetting
|
||||
|
||||
|
||||
class SettingsAdmin(ImportExportModelAdmin):
|
||||
@ -13,4 +13,10 @@ class SettingsAdmin(ImportExportModelAdmin):
|
||||
list_display = ('key', 'value')
|
||||
|
||||
|
||||
class UserSettingsAdmin(ImportExportModelAdmin):
|
||||
|
||||
list_display = ('key', 'value', 'user', )
|
||||
|
||||
|
||||
admin.site.register(InvenTreeSetting, SettingsAdmin)
|
||||
admin.site.register(InvenTreeUserSetting, UserSettingsAdmin)
|
||||
|
@ -53,17 +53,20 @@ class FileManager:
|
||||
|
||||
ext = os.path.splitext(file.name)[-1].lower().replace('.', '')
|
||||
|
||||
if ext in ['csv', 'tsv', ]:
|
||||
# These file formats need string decoding
|
||||
raw_data = file.read().decode('utf-8')
|
||||
# Reset stream position to beginning of file
|
||||
file.seek(0)
|
||||
elif ext in ['xls', 'xlsx', 'json', 'yaml', ]:
|
||||
raw_data = file.read()
|
||||
# Reset stream position to beginning of file
|
||||
file.seek(0)
|
||||
else:
|
||||
raise ValidationError(_(f'Unsupported file format: {ext.upper()}'))
|
||||
try:
|
||||
if ext in ['csv', 'tsv', ]:
|
||||
# These file formats need string decoding
|
||||
raw_data = file.read().decode('utf-8')
|
||||
# Reset stream position to beginning of file
|
||||
file.seek(0)
|
||||
elif ext in ['xls', 'xlsx', 'json', 'yaml', ]:
|
||||
raw_data = file.read()
|
||||
# Reset stream position to beginning of file
|
||||
file.seek(0)
|
||||
else:
|
||||
raise ValidationError(_(f'Unsupported file format: {ext.upper()}'))
|
||||
except UnicodeEncodeError:
|
||||
raise ValidationError(_('Error reading file (invalid encoding)'))
|
||||
|
||||
try:
|
||||
cleaned_data = tablib.Dataset().load(raw_data, format=ext)
|
||||
|
33
InvenTree/common/migrations/0011_auto_20210722_2114.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -11,6 +11,7 @@ import decimal
|
||||
import math
|
||||
|
||||
from django.db import models, transaction
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.utils import IntegrityError, OperationalError
|
||||
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.exceptions import MissingRate
|
||||
|
||||
import common.settings
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.core.validators import MinValueValidator, URLValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
@ -28,7 +27,397 @@ import InvenTree.helpers
|
||||
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
|
||||
single values (e.g. one-off settings values).
|
||||
@ -279,6 +668,13 @@ class InvenTreeSetting(models.Model):
|
||||
'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': {
|
||||
'name': _('Stock Expiry'),
|
||||
'description': _('Enable stock expiry functionality'),
|
||||
@ -357,379 +753,144 @@ class InvenTreeSetting(models.Model):
|
||||
verbose_name = "InvenTree Setting"
|
||||
verbose_name_plural = "InvenTree Settings"
|
||||
|
||||
@classmethod
|
||||
def get_setting_name(cls, key):
|
||||
"""
|
||||
Return the name of a particular setting.
|
||||
key = models.CharField(
|
||||
max_length=50,
|
||||
blank=False,
|
||||
unique=True,
|
||||
help_text=_('Settings key (must be unique - case insensitive'),
|
||||
)
|
||||
|
||||
If it does not exist, return an empty string.
|
||||
"""
|
||||
|
||||
key = str(key).strip().upper()
|
||||
class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
"""
|
||||
An InvenTreeSetting object with a usercontext
|
||||
"""
|
||||
|
||||
if key in cls.GLOBAL_SETTINGS:
|
||||
setting = cls.GLOBAL_SETTINGS[key]
|
||||
return setting.get('name', '')
|
||||
else:
|
||||
return ''
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
class Meta:
|
||||
verbose_name = "InvenTree User Setting"
|
||||
verbose_name_plural = "InvenTree User Settings"
|
||||
constraints = [
|
||||
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
|
||||
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_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 get_setting_object(cls, key, user):
|
||||
return super().get_setting_object(key, user=user)
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
""" 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.
|
||||
"""
|
||||
return super().validate_unique(exclude=exclude, user=self.user)
|
||||
|
||||
super().validate_unique(exclude)
|
||||
|
||||
try:
|
||||
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
|
||||
@classmethod
|
||||
def get_filters(cls, key, **kwargs):
|
||||
return {'key__iexact': key, 'user__id__iexact': kwargs['user'].id}
|
||||
|
||||
|
||||
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 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):
|
||||
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:
|
||||
# Default currency selection
|
||||
currency = common.settings.currency_code_default()
|
||||
currency = currency_code_default()
|
||||
|
||||
pb_min = None
|
||||
for pb in price_breaks:
|
||||
|
@ -8,15 +8,19 @@ from __future__ import unicode_literals
|
||||
from moneyed import CURRENCIES
|
||||
from django.conf import settings
|
||||
|
||||
import common.models
|
||||
|
||||
|
||||
def currency_code_default():
|
||||
"""
|
||||
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:
|
||||
code = 'USD'
|
||||
@ -42,5 +46,6 @@ def stock_expiry_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')
|
||||
|
@ -45,8 +45,8 @@ class SettingEdit(AjaxUpdateView):
|
||||
|
||||
ctx['key'] = setting.key
|
||||
ctx['value'] = setting.value
|
||||
ctx['name'] = models.InvenTreeSetting.get_setting_name(setting.key)
|
||||
ctx['description'] = models.InvenTreeSetting.get_setting_description(setting.key)
|
||||
ctx['name'] = self.model.get_setting_name(setting.key)
|
||||
ctx['description'] = self.model.get_setting_description(setting.key)
|
||||
|
||||
return ctx
|
||||
|
||||
@ -69,12 +69,12 @@ class SettingEdit(AjaxUpdateView):
|
||||
self.object.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:
|
||||
form.fields['value'].label = name
|
||||
|
||||
description = models.InvenTreeSetting.get_setting_description(setting.key)
|
||||
description = self.model.get_setting_description(setting.key)
|
||||
|
||||
if description:
|
||||
form.fields['value'].help_text = description
|
||||
@ -111,6 +111,18 @@ class SettingEdit(AjaxUpdateView):
|
||||
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):
|
||||
""" Setup basic methods of multi-step form
|
||||
|
||||
|
@ -24,14 +24,14 @@
|
||||
|
||||
{% comment "for later" %}
|
||||
<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>
|
||||
{% trans "Stock" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<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>
|
||||
{% trans "Orders" %}
|
||||
</a>
|
||||
|
@ -30,11 +30,9 @@ company_urls = [
|
||||
|
||||
manufacturer_part_urls = [
|
||||
|
||||
url(r'^(?P<pk>\d+)/', include([
|
||||
url('^.*$', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part.html'), name='manufacturer-part-detail'),
|
||||
])),
|
||||
url(r'^(?P<pk>\d+)/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part.html'), name='manufacturer-part-detail'),
|
||||
]
|
||||
|
||||
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'),
|
||||
]
|
||||
|
@ -296,7 +296,9 @@ class StockItemLabel(LabelTemplate):
|
||||
'uid': stock_item.uid,
|
||||
'qr_data': stock_item.format_barcode(brief=True),
|
||||
'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,
|
||||
'qr_data': part.format_barcode(brief=True),
|
||||
'qr_url': part.format_barcode(url=True, request=request),
|
||||
'parameters': part.parameters_map(),
|
||||
}
|
||||
|
@ -84,9 +84,9 @@ class ReceivePurchaseOrderForm(HelperForm):
|
||||
|
||||
location = TreeNodeChoiceField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
required=True,
|
||||
required=False,
|
||||
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:
|
||||
|
@ -1,50 +1,74 @@
|
||||
{% extends "order/order_base.html" %}
|
||||
{% extends "order/purchase_order_detail.html" %}
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Upload File for Purchase Order" %}
|
||||
{{ wizard.form.media }}
|
||||
{% block menubar %}
|
||||
<ul class='list-group'>
|
||||
<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 %}
|
||||
|
||||
{% block details %}
|
||||
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
|
||||
{% block page_content %}
|
||||
|
||||
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
|
||||
{% if description %}- {{ description }}{% endif %}</p>
|
||||
<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 %}
|
||||
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
|
||||
|
||||
{% block form_alert %}
|
||||
{% endblock form_alert %}
|
||||
<p>{% blocktrans with step=wizard.steps.step1 count=wizard.steps.count %}Step {{step}} of {{count}}{% endblocktrans %}
|
||||
{% if description %}- {{ description }}{% endif %}</p>
|
||||
|
||||
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% block form_alert %}
|
||||
{% endblock form_alert %}
|
||||
|
||||
{% block form_buttons_top %}
|
||||
{% endblock form_buttons_top %}
|
||||
<form action="" method="post" class='js-modal-form' enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
|
||||
{{ wizard.management_form }}
|
||||
{% block form_content %}
|
||||
{% crispy wizard.form %}
|
||||
{% endblock form_content %}
|
||||
</table>
|
||||
{% block form_buttons_top %}
|
||||
{% endblock form_buttons_top %}
|
||||
|
||||
{% block form_buttons_bottom %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="save btn btn-default">{% trans "Upload File" %}</button>
|
||||
</form>
|
||||
{% endblock form_buttons_bottom %}
|
||||
<table class='table table-striped' style='margin-top: 12px; margin-bottom: 0px'>
|
||||
{{ wizard.management_form }}
|
||||
{% block form_content %}
|
||||
{% crispy wizard.form %}
|
||||
{% endblock form_content %}
|
||||
</table>
|
||||
|
||||
{% else %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Order is already processed. Files cannot be uploaded." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock details %}
|
||||
{% block form_buttons_bottom %}
|
||||
{% if wizard.steps.prev %}
|
||||
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="save btn btn-default">{% trans "Previous Step" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="save btn btn-default">{% trans "Upload File" %}</button>
|
||||
</form>
|
||||
{% endblock form_buttons_bottom %}
|
||||
|
||||
{% else %}
|
||||
<div class='alert alert-danger alert-block' role='alert'>
|
||||
{% trans "Order is already processed. Files cannot be uploaded." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock details %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
@ -15,14 +15,6 @@
|
||||
{% trans "Order Items" %}
|
||||
</a>
|
||||
</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" %}'>
|
||||
<a href='#' id='select-received-items' class='nav-toggle'>
|
||||
<span class='fas fa-sign-in-alt side-icon'></span>
|
||||
|
@ -22,6 +22,9 @@
|
||||
<button type='button' class='btn btn-primary' id='new-po-line'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
|
||||
</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 %}
|
||||
</div>
|
||||
|
||||
|
@ -12,7 +12,7 @@
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
<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'>
|
||||
<tr>
|
||||
@ -55,7 +55,14 @@
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
{% crispy form %}
|
||||
|
||||
<div id='form-errors'>{{ form_errors }}</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
@ -393,16 +393,7 @@ class PurchaseOrderUpload(FileManagementFormView):
|
||||
p_val = row['data'][p_idx]['cell']
|
||||
|
||||
if p_val:
|
||||
# Delete commas
|
||||
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
|
||||
row['purchase_price'] = p_val
|
||||
|
||||
# Check if there is a column corresponding to "reference"
|
||||
if r_idx >= 0:
|
||||
@ -500,6 +491,7 @@ class PurchaseOrderReceive(AjaxUpdateView):
|
||||
ctx = {
|
||||
'order': self.order,
|
||||
'lines': self.lines,
|
||||
'stock_locations': StockLocation.objects.all(),
|
||||
}
|
||||
|
||||
return ctx
|
||||
@ -552,6 +544,7 @@ class PurchaseOrderReceive(AjaxUpdateView):
|
||||
|
||||
self.request = request
|
||||
self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
|
||||
errors = False
|
||||
|
||||
self.lines = []
|
||||
self.destination = None
|
||||
@ -566,12 +559,6 @@ class PurchaseOrderReceive(AjaxUpdateView):
|
||||
except (StockLocation.DoesNotExist, ValueError):
|
||||
pass
|
||||
|
||||
errors = False
|
||||
|
||||
if self.destination is None:
|
||||
errors = True
|
||||
msg = _("No destination set")
|
||||
|
||||
# Extract information on all submitted line items
|
||||
for item in request.POST:
|
||||
if item.startswith('line-'):
|
||||
@ -596,6 +583,21 @@ class PurchaseOrderReceive(AjaxUpdateView):
|
||||
else:
|
||||
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
|
||||
if not line.order == self.order:
|
||||
# TODO - Display a non-field error?
|
||||
@ -654,7 +656,7 @@ class PurchaseOrderReceive(AjaxUpdateView):
|
||||
|
||||
self.order.receive_line_item(
|
||||
line,
|
||||
self.destination,
|
||||
line.destination,
|
||||
line.receive_quantity,
|
||||
self.request.user,
|
||||
status=line.status_code,
|
||||
|
@ -105,6 +105,20 @@ class CategoryList(generics.ListCreateAPIView):
|
||||
except (ValueError, PartCategory.DoesNotExist):
|
||||
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
|
||||
|
||||
filter_backends = [
|
||||
@ -361,7 +375,6 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = part_serializers.PartSerializer.prefetch_queryset(queryset)
|
||||
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
@ -619,8 +632,6 @@ class PartList(generics.ListCreateAPIView):
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = part_serializers.PartSerializer.prefetch_queryset(queryset)
|
||||
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
@ -633,10 +644,6 @@ class PartList(generics.ListCreateAPIView):
|
||||
|
||||
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)
|
||||
|
||||
# Filter by "uses" query - Limit to parts which use the provided part
|
||||
@ -651,6 +658,20 @@ class PartList(generics.ListCreateAPIView):
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
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'?
|
||||
ancestor = params.get('ancestor', None)
|
||||
|
||||
|
@ -27,6 +27,8 @@ from markdownx.models import MarkdownxField
|
||||
from django_cleanup import cleanup
|
||||
|
||||
from mptt.models import TreeForeignKey, MPTTModel
|
||||
from mptt.exceptions import InvalidMove
|
||||
from mptt.managers import TreeManager
|
||||
|
||||
from stdimage.models import StdImageField
|
||||
|
||||
@ -284,6 +286,24 @@ def match_part_names(match, threshold=80, reverse=True, compare_length=False):
|
||||
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
|
||||
class Part(MPTTModel):
|
||||
""" 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)
|
||||
"""
|
||||
|
||||
objects = PartManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Part")
|
||||
verbose_name_plural = _("Parts")
|
||||
@ -338,6 +360,17 @@ class Part(MPTTModel):
|
||||
|
||||
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):
|
||||
"""
|
||||
Return some useful context data about this part for template rendering
|
||||
@ -393,7 +426,12 @@ class Part(MPTTModel):
|
||||
|
||||
self.full_clean()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
try:
|
||||
super().save(*args, **kwargs)
|
||||
except InvalidMove:
|
||||
raise ValidationError({
|
||||
'variant_of': _('Invalid choice for parent part'),
|
||||
})
|
||||
|
||||
if add_category_templates:
|
||||
# Get part category
|
||||
@ -1473,16 +1511,16 @@ class Part(MPTTModel):
|
||||
return self.supplier_parts.count()
|
||||
|
||||
@property
|
||||
def has_pricing_info(self):
|
||||
def has_pricing_info(self, internal=False):
|
||||
""" 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
|
||||
def has_complete_bom_pricing(self):
|
||||
""" 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'):
|
||||
if not item.sub_part.has_pricing_info:
|
||||
if not item.sub_part.has_pricing_info(use_internal):
|
||||
return False
|
||||
|
||||
return True
|
||||
@ -1866,6 +1904,23 @@ class Part(MPTTModel):
|
||||
|
||||
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
|
||||
def has_variants(self):
|
||||
""" Check if this Part object has variants underneath it. """
|
||||
|
@ -215,25 +215,6 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
if category_detail is not True:
|
||||
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
|
||||
def annotate_queryset(queryset):
|
||||
"""
|
||||
|
@ -18,7 +18,7 @@ from InvenTree import version, settings
|
||||
|
||||
import InvenTree.helpers
|
||||
|
||||
from common.models import InvenTreeSetting, ColorTheme
|
||||
from common.models import InvenTreeSetting, ColorTheme, InvenTreeUserSetting
|
||||
from common.settings import currency_code_default
|
||||
|
||||
register = template.Library()
|
||||
@ -69,6 +69,12 @@ def add(x, y, *args, **kwargs):
|
||||
return x + y
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def to_list(*args):
|
||||
""" Return the input arguments as list """
|
||||
return args
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def part_allocation_count(build, part, *args, **kwargs):
|
||||
""" 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
|
||||
(Or return None if the setting does not exist)
|
||||
if a user-setting was requested return that
|
||||
"""
|
||||
|
||||
setting = InvenTreeSetting.get_setting_object(key)
|
||||
|
||||
return setting
|
||||
if 'user' in kwargs:
|
||||
return InvenTreeUserSetting.get_setting_object(key, user=kwargs['user'])
|
||||
return InvenTreeSetting.get_setting_object(key)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
@ -195,6 +202,8 @@ def settings_value(key, *args, **kwargs):
|
||||
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)
|
||||
|
||||
|
||||
|
@ -99,7 +99,7 @@ class CategoryTest(TestCase):
|
||||
""" Test that the Category parameters are correctly fetched """
|
||||
|
||||
# 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)
|
||||
fasteners = self.fasteners.prefetch_parts_parameters()
|
||||
# Iterate through all parts and parameters
|
||||
|
@ -356,6 +356,7 @@ class TestReport(ReportTemplateBase):
|
||||
'stock_item': stock_item,
|
||||
'serial': stock_item.serial,
|
||||
'part': stock_item.part,
|
||||
'parameters': stock_item.part.parameters_map(),
|
||||
'results': stock_item.testResultMap(include_installed=self.include_installed),
|
||||
'result_list': stock_item.testResultList(include_installed=self.include_installed),
|
||||
'installed_items': stock_item.get_installed_items(cascade=True),
|
||||
|
@ -84,7 +84,6 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
queryset = StockItemSerializer.prefetch_queryset(queryset)
|
||||
queryset = StockItemSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
@ -344,6 +343,20 @@ class StockLocationList(generics.ListCreateAPIView):
|
||||
except (ValueError, StockLocation.DoesNotExist):
|
||||
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
|
||||
|
||||
filter_backends = [
|
||||
@ -637,7 +650,6 @@ class StockList(generics.ListCreateAPIView):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = StockItemSerializer.prefetch_queryset(queryset)
|
||||
queryset = StockItemSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
@ -721,6 +733,20 @@ class StockList(generics.ListCreateAPIView):
|
||||
if 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?
|
||||
allocated = params.get('allocated', None)
|
||||
|
||||
|
@ -23,6 +23,7 @@ from django.dispatch import receiver
|
||||
from markdownx.models import MarkdownxField
|
||||
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
from mptt.managers import TreeManager
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from datetime import datetime, timedelta
|
||||
@ -130,6 +131,31 @@ def before_delete_stock_location(sender, instance, using, **kwargs):
|
||||
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):
|
||||
"""
|
||||
A StockItem object represents a quantity of physical instances of a part.
|
||||
@ -165,6 +191,17 @@ class StockItem(MPTTModel):
|
||||
def get_api_url():
|
||||
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"
|
||||
IN_STOCK_FILTER = Q(
|
||||
quantity__gt=0,
|
||||
|
@ -70,29 +70,6 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
||||
- 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
|
||||
def annotate_queryset(queryset):
|
||||
"""
|
||||
|
@ -272,7 +272,7 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-user-tie'></span></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>
|
||||
{% endif %}
|
||||
{% if item.belongs_to %}
|
||||
|
@ -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" %}');
|
||||
|
||||
{% if setting_part_starred %}
|
||||
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('bom-validation', '{% trans "BOM Waiting Validation" %}', 'fa-times-circle');
|
||||
|
||||
|
||||
loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", {
|
||||
params: {
|
||||
ordering: "-creation_date",
|
||||
@ -107,30 +120,37 @@ loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", {
|
||||
},
|
||||
name: 'latest_parts',
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
loadSimplePartTable("#table-starred-parts", "{% url 'api-part-list' %}", {
|
||||
params: {
|
||||
"starred": true,
|
||||
},
|
||||
name: 'starred_parts',
|
||||
});
|
||||
|
||||
{% if setting_bom_validation %}
|
||||
addHeaderAction('bom-validation', '{% trans "BOM Waiting Validation" %}', 'fa-times-circle');
|
||||
loadSimplePartTable("#table-bom-validation", "{% url 'api-part-list' %}", {
|
||||
params: {
|
||||
"bom_valid": false,
|
||||
},
|
||||
name: 'bom_invalid_parts',
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if roles.stock.view %}
|
||||
addHeaderTitle('{% trans "Stock" %}');
|
||||
addHeaderAction('recently-updated-stock', '{% trans "Recently Updated" %}', 'fa-clock');
|
||||
addHeaderAction('low-stock', '{% trans "Low Stock" %}', 'fa-shopping-cart');
|
||||
addHeaderAction('depleted-stock', '{% trans "Depleted Stock" %}', 'fa-times');
|
||||
addHeaderAction('stock-to-build', '{% trans "Required for Build Orders" %}', 'fa-bullhorn');
|
||||
{% settings_value 'HOMEPAGE_STOCK_RECENT' user=request.user as setting_stock_recent %}
|
||||
{% settings_value 'HOMEPAGE_STOCK_LOW' user=request.user as setting_stock_low %}
|
||||
{% settings_value 'HOMEPAGE_STOCK_DEPLETED' user=request.user as setting_stock_depleted %}
|
||||
{% settings_value 'HOMEPAGE_STOCK_NEEDED' user=request.user as setting_stock_needed %}
|
||||
{% settings_value "STOCK_ENABLE_EXPIRY" as expiry %}
|
||||
{% 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'), {
|
||||
params: {
|
||||
part_detail: true,
|
||||
@ -140,12 +160,43 @@ loadStockTable($('#table-recently-updated-stock'), {
|
||||
name: 'recently-updated-stock',
|
||||
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 %}
|
||||
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"), {
|
||||
params: {
|
||||
expired: true,
|
||||
@ -153,7 +204,10 @@ loadStockTable($("#table-expired-stock"), {
|
||||
part_detail: true,
|
||||
},
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if setting_stock_stale %}
|
||||
addHeaderAction('stale-stock', '{% trans "Stale Stock" %}', 'fa-stopwatch');
|
||||
loadStockTable($("#table-stale-stock"), {
|
||||
params: {
|
||||
stale: true,
|
||||
@ -164,34 +218,18 @@ loadStockTable($("#table-stale-stock"), {
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
loadSimplePartTable("#table-low-stock", "{% url 'api-part-list' %}", {
|
||||
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 %}
|
||||
addHeaderTitle('{% trans "Build Orders" %}');
|
||||
addHeaderAction('build-pending', '{% trans "Build Orders In Progress" %}', 'fa-cogs');
|
||||
addHeaderAction('build-overdue', '{% trans "Overdue Build Orders" %}', 'fa-calendar-times');
|
||||
{% settings_value 'HOMEPAGE_BUILD_PENDING' user=request.user as setting_build_pending %}
|
||||
{% settings_value 'HOMEPAGE_BUILD_OVERDUE' user=request.user as setting_build_overdue %}
|
||||
{% to_list setting_build_pending setting_build_overdue as settings_list_build %}
|
||||
|
||||
{% 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", {
|
||||
url: "{% url 'api-build-list' %}",
|
||||
params: {
|
||||
@ -199,7 +237,10 @@ loadBuildTable("#table-build-pending", {
|
||||
},
|
||||
disableFilters: true,
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if setting_build_overdue %}
|
||||
addHeaderAction('build-overdue', '{% trans "Overdue Build Orders" %}', 'fa-calendar-times');
|
||||
loadBuildTable("#table-build-overdue", {
|
||||
url: "{% url 'api-build-list' %}",
|
||||
params: {
|
||||
@ -209,11 +250,17 @@ loadBuildTable("#table-build-overdue", {
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if roles.purchase_order.view %}
|
||||
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');
|
||||
{% endif %}
|
||||
|
||||
{% 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", {
|
||||
url: "{% url 'api-po-list' %}",
|
||||
params: {
|
||||
@ -221,7 +268,10 @@ loadPurchaseOrderTable("#table-po-outstanding", {
|
||||
outstanding: true,
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if setting_po_overdue %}
|
||||
addHeaderAction('po-overdue', '{% trans "Overdue Purchase Orders" %}', 'fa-calendar-times');
|
||||
loadPurchaseOrderTable("#table-po-overdue", {
|
||||
url: "{% url 'api-po-list' %}",
|
||||
params: {
|
||||
@ -229,14 +279,19 @@ loadPurchaseOrderTable("#table-po-overdue", {
|
||||
overdue: true,
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% if roles.sales_order.view %}
|
||||
addHeaderTitle('{% trans "Sales Orders" %}');
|
||||
addHeaderAction('so-outstanding', '{% trans "Outstanding Sales Orders" %}', 'fa-sign-out-alt');
|
||||
addHeaderAction('so-overdue', '{% trans "Overdue Sales Orders" %}', 'fa-calendar-times');
|
||||
{% settings_value 'HOMEPAGE_SO_OUTSTANDING' user=request.user as setting_so_outstanding %}
|
||||
{% settings_value 'HOMEPAGE_SO_OVERDUE' user=request.user as setting_so_overdue %}
|
||||
{% to_list setting_so_outstanding setting_so_overdue as settings_list_so %}
|
||||
|
||||
{% 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", {
|
||||
url: "{% url 'api-so-list' %}",
|
||||
params: {
|
||||
@ -244,7 +299,10 @@ loadSalesOrderTable("#table-so-outstanding", {
|
||||
outstanding: true,
|
||||
},
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if setting_so_overdue %}
|
||||
addHeaderAction('so-overdue', '{% trans "Overdue Sales Orders" %}', 'fa-calendar-times');
|
||||
loadSalesOrderTable("#table-so-overdue", {
|
||||
url: "{% url 'api-so-list' %}",
|
||||
params: {
|
||||
@ -252,6 +310,7 @@ loadSalesOrderTable("#table-so-overdue", {
|
||||
customer_detail: true,
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
@ -62,6 +62,4 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
@ -31,4 +31,11 @@
|
||||
</tbody>
|
||||
</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 %}
|
||||
|
@ -1,7 +1,12 @@
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
|
||||
{% setting_object key as setting %}
|
||||
{% if user_setting %}
|
||||
{% setting_object key user=request.user as setting %}
|
||||
{% else %}
|
||||
{% setting_object key as setting %}
|
||||
{% endif %}
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
{% if icon %}
|
||||
@ -28,7 +33,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -45,9 +45,14 @@
|
||||
$('table').find('.btn-edit-setting').click(function() {
|
||||
var setting = $(this).attr('setting');
|
||||
var pk = $(this).attr('pk');
|
||||
var url = `/settings/${pk}/edit/`;
|
||||
|
||||
if ($(this).attr('user')){
|
||||
url += `user/`;
|
||||
}
|
||||
|
||||
launchModalForm(
|
||||
`/settings/${pk}/edit/`,
|
||||
url,
|
||||
{
|
||||
reload: true,
|
||||
}
|
||||
|
@ -8,6 +8,9 @@
|
||||
<li{% ifequal tab 'theme' %} class='active'{% endifequal %}>
|
||||
<a href="{% url 'settings-appearance' %}"><span class='fas fa-fill'></span> {% trans "Appearance" %}</a>
|
||||
</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>
|
||||
{% if user.is_staff %}
|
||||
<h4><span class='fas fa-cogs'></span> {% trans "InvenTree Settings" %}</h4>
|
||||
|
41
InvenTree/templates/InvenTree/settings/user_settings.html
Normal 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 %}
|
@ -142,11 +142,11 @@
|
||||
<script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script>
|
||||
|
||||
<!-- 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/sidenav.js' %}"></script>
|
||||
|
||||
<!-- 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 'attachment.js' %}"></script>
|
||||
<script type='text/javascript' src="{% i18n_static 'barcode.js' %}"></script>
|
||||
|
@ -350,6 +350,12 @@ function constructFormBody(fields, options) {
|
||||
for(field in fields) {
|
||||
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];
|
||||
|
||||
// Copy custom options across to the fields object
|
||||
|
@ -1,3 +1,5 @@
|
||||
{% load inventree_extras %}
|
||||
|
||||
function attachClipboard(selector, containerselector, textElement) {
|
||||
// set container
|
||||
if (containerselector){
|
||||
@ -13,7 +15,8 @@ function attachClipboard(selector, containerselector, textElement) {
|
||||
}
|
||||
} else {
|
||||
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', 'modal-about'); // modals
|
||||
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) {
|
@ -3,7 +3,7 @@
|
||||
<form class="navbar-form navbar-left" action="{% url 'search' %}" method='post'>
|
||||
{% csrf_token %}
|
||||
<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>
|
||||
<button type="submit" id='search-submit' class="btn btn-default" title='{% trans "Search" %}'>
|
||||
<span class='fas fa-search'></span>
|
||||
|
@ -141,6 +141,7 @@ class RuleSet(models.Model):
|
||||
# Models which currently do not require permissions
|
||||
'common_colortheme',
|
||||
'common_inventreesetting',
|
||||
'common_inventreeusersetting',
|
||||
'company_contact',
|
||||
'users_owner',
|
||||
|
||||
|
@ -34,15 +34,6 @@ InvenTree is supported by a [companion mobile app](https://inventree.readthedocs
|
||||
|
||||
# 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**.
|
||||
|
||||
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!
|
||||
|