2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-16 12:05:53 +00:00

Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver
2021-07-23 12:54:16 +10:00
42 changed files with 2573 additions and 88 deletions

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

@ -111,6 +111,7 @@ dynamic_javascript_urls = [
url(r'^company.js', DynamicJsView.as_view(template_name='js/company.js'), name='company.js'), url(r'^company.js', DynamicJsView.as_view(template_name='js/company.js'), name='company.js'),
url(r'^filters.js', DynamicJsView.as_view(template_name='js/filters.js'), name='filters.js'), url(r'^filters.js', DynamicJsView.as_view(template_name='js/filters.js'), name='filters.js'),
url(r'^forms.js', DynamicJsView.as_view(template_name='js/forms.js'), name='forms.js'), url(r'^forms.js', DynamicJsView.as_view(template_name='js/forms.js'), name='forms.js'),
url(r'^inventree.js', DynamicJsView.as_view(template_name='js/inventree.js'), name='inventree.js'),
url(r'^label.js', DynamicJsView.as_view(template_name='js/label.js'), name='label.js'), url(r'^label.js', DynamicJsView.as_view(template_name='js/label.js'), name='label.js'),
url(r'^model_renderers.js', DynamicJsView.as_view(template_name='js/model_renderers.js'), name='model_renderers.js'), url(r'^model_renderers.js', DynamicJsView.as_view(template_name='js/model_renderers.js'), name='model_renderers.js'),
url(r'^modals.js', DynamicJsView.as_view(template_name='js/modals.js'), name='modals.js'), url(r'^modals.js', DynamicJsView.as_view(template_name='js/modals.js'), name='modals.js'),

View File

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

View File

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

View File

@ -277,6 +277,13 @@ class InvenTreeSetting(models.Model):
'validator': bool, 'validator': bool,
}, },
'SEARCH_PREVIEW_RESULTS': {
'name': _('Search Preview Results'),
'description': _('Number of results to show in search preview window'),
'default': 10,
'validator': [int, MinValueValidator(1)]
},
'STOCK_ENABLE_EXPIRY': { 'STOCK_ENABLE_EXPIRY': {
'name': _('Stock Expiry'), 'name': _('Stock Expiry'),
'description': _('Enable stock expiry functionality'), 'description': _('Enable stock expiry functionality'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -491,6 +491,7 @@ class PurchaseOrderReceive(AjaxUpdateView):
ctx = { ctx = {
'order': self.order, 'order': self.order,
'lines': self.lines, 'lines': self.lines,
'stock_locations': StockLocation.objects.all(),
} }
return ctx return ctx
@ -543,6 +544,7 @@ class PurchaseOrderReceive(AjaxUpdateView):
self.request = request self.request = request
self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk']) self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
errors = False
self.lines = [] self.lines = []
self.destination = None self.destination = None
@ -557,12 +559,6 @@ class PurchaseOrderReceive(AjaxUpdateView):
except (StockLocation.DoesNotExist, ValueError): except (StockLocation.DoesNotExist, ValueError):
pass pass
errors = False
if self.destination is None:
errors = True
msg = _("No destination set")
# Extract information on all submitted line items # Extract information on all submitted line items
for item in request.POST: for item in request.POST:
if item.startswith('line-'): if item.startswith('line-'):
@ -587,6 +583,21 @@ class PurchaseOrderReceive(AjaxUpdateView):
else: else:
line.status_code = StockStatus.OK line.status_code = StockStatus.OK
# Check the destination field
line.destination = None
if self.destination:
# If global destination is set, overwrite line value
line.destination = self.destination
else:
destination_key = f'destination-{pk}'
destination = request.POST.get(destination_key, None)
if destination:
try:
line.destination = StockLocation.objects.get(pk=destination)
except (StockLocation.DoesNotExist, ValueError):
pass
# Check that line matches the order # Check that line matches the order
if not line.order == self.order: if not line.order == self.order:
# TODO - Display a non-field error? # TODO - Display a non-field error?
@ -645,7 +656,7 @@ class PurchaseOrderReceive(AjaxUpdateView):
self.order.receive_line_item( self.order.receive_line_item(
line, line,
self.destination, line.destination,
line.receive_quantity, line.receive_quantity,
self.request.user, self.request.user,
status=line.status_code, status=line.status_code,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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