Merge branch 'inventree:master' into matmair/issue2279

This commit is contained in:
Matthias Mair 2022-03-03 23:55:04 +01:00 committed by GitHub
commit 6071f6545b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 24986 additions and 23619 deletions

View File

@ -4,8 +4,13 @@ import logging
from django.apps import AppConfig from django.apps import AppConfig
from django.core.exceptions import AppRegistryNotReady from django.core.exceptions import AppRegistryNotReady
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.utils import IntegrityError
from InvenTree.ready import isInTestMode, canAppAccessDatabase from InvenTree.ready import isInTestMode, canAppAccessDatabase
from .config import get_setting
import InvenTree.tasks import InvenTree.tasks
@ -26,6 +31,9 @@ class InvenTreeConfig(AppConfig):
if not isInTestMode(): if not isInTestMode():
self.update_exchange_rates() self.update_exchange_rates()
if canAppAccessDatabase() or settings.TESTING_ENV:
self.add_user_on_startup()
def remove_obsolete_tasks(self): def remove_obsolete_tasks(self):
""" """
Delete any obsolete scheduled tasks in the database Delete any obsolete scheduled tasks in the database
@ -138,3 +146,54 @@ class InvenTreeConfig(AppConfig):
update_exchange_rates() update_exchange_rates()
except Exception as e: except Exception as e:
logger.error(f"Error updating exchange rates: {e}") logger.error(f"Error updating exchange rates: {e}")
def add_user_on_startup(self):
"""Add a user on startup"""
# stop if checks were already created
if hasattr(settings, 'USER_ADDED') and settings.USER_ADDED:
return
# get values
add_user = get_setting(
'INVENTREE_ADMIN_USER',
settings.CONFIG.get('admin_user', False)
)
add_email = get_setting(
'INVENTREE_ADMIN_EMAIL',
settings.CONFIG.get('admin_email', False)
)
add_password = get_setting(
'INVENTREE_ADMIN_PASSWORD',
settings.CONFIG.get('admin_password', False)
)
# check if all values are present
set_variables = 0
for tested_var in [add_user, add_email, add_password]:
if tested_var:
set_variables += 1
# no variable set -> do not try anything
if set_variables == 0:
settings.USER_ADDED = True
return
# not all needed variables set
if set_variables < 3:
logger.warn('Not all required settings for adding a user on startup are present:\nINVENTREE_SET_USER, INVENTREE_SET_EMAIL, INVENTREE_SET_PASSWORD')
settings.USER_ADDED = True
return
# good to go -> create user
user = get_user_model()
try:
with transaction.atomic():
new_user = user.objects.create_superuser(add_user, add_email, add_password)
logger.info(f'User {str(new_user)} was created!')
except IntegrityError as _e:
logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}')
if settings.TESTING_ENV:
raise _e
# do not try again
settings.USER_ADDED = True

View File

@ -37,6 +37,8 @@ def _is_true(x):
# Determine if we are running in "test" mode e.g. "manage.py test" # Determine if we are running in "test" mode e.g. "manage.py test"
TESTING = 'test' in sys.argv TESTING = 'test' in sys.argv
# Are enviroment variables manipulated by tests? Needs to be set by testing code
TESTING_ENV = False
# New requirement for django 3.2+ # New requirement for django 3.2+
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'

View File

@ -1,9 +1,12 @@
import json import json
from test.support import EnvironmentVarGuard
from django.test import TestCase from django.test import TestCase, override_settings
import django.core.exceptions as django_exceptions import django.core.exceptions as django_exceptions
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.contrib.auth import get_user_model
from django.conf import settings
from djmoney.money import Money from djmoney.money import Money
from djmoney.contrib.exchange.models import Rate, convert_money from djmoney.contrib.exchange.models import Rate, convert_money
@ -407,3 +410,46 @@ class TestStatus(TestCase):
def test_Importing(self): def test_Importing(self):
self.assertEqual(ready.isImportingData(), False) self.assertEqual(ready.isImportingData(), False)
class TestSettings(TestCase):
"""
Unit tests for settings
"""
def setUp(self) -> None:
self.user_mdl = get_user_model()
self.env = EnvironmentVarGuard()
def run_reload(self):
from plugin import registry
with self.env:
settings.USER_ADDED = False
registry.reload_plugins()
@override_settings(TESTING_ENV=True)
def test_set_user_to_few(self):
# add shortcut
user_count = self.user_mdl.objects.count
# enable testing mode
settings.TESTING_ENV = True
# nothing set
self.run_reload()
self.assertEqual(user_count(), 0)
# not enough set
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
self.run_reload()
self.assertEqual(user_count(), 0)
# enough set
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password
self.run_reload()
self.assertEqual(user_count(), 1)
# make sure to clean up
settings.TESTING_ENV = False

View File

@ -169,7 +169,9 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='assigned-stock-button-toolbar'> <div id='assigned-stock-button-toolbar'>
{% include "filter_list.html" with id="customerstock" %} <div class='btn-group' role='group'>
{% include "filter_list.html" with id="customerstock" %}
</div>
</div> </div>
<table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table> <table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table>
@ -282,12 +284,6 @@
filterKey: "companystock", filterKey: "companystock",
}); });
$("#stock-export").click(function() {
exportStock({
supplier: {{ company.id }}
});
});
{% if company.is_manufacturer %} {% if company.is_manufacturer %}
function reloadManufacturerPartTable() { function reloadManufacturerPartTable() {

View File

@ -308,14 +308,6 @@ loadStockTable($("#stock-table"), {
url: "{% url 'api-stock-list' %}", url: "{% url 'api-stock-list' %}",
}); });
$("#stock-export").click(function() {
exportStock({
supplier_part: {{ part.pk }},
});
});
$("#item-create").click(function() { $("#item-create").click(function() {
createNewStockItem({ createNewStockItem({
data: { data: {

View File

@ -154,6 +154,11 @@ static_root: '/home/inventree/data/static'
# Use environment variable INVENTREE_LOGIN_ATTEMPTS # Use environment variable INVENTREE_LOGIN_ATTEMPTS
#login_attempts: 5 #login_attempts: 5
# Add new user on first startup
#admin_user: admin
#admin_email: info@example.com
#admin_password: inventree
# Permit custom authentication backends # Permit custom authentication backends
#authentication_backends: #authentication_backends:
# - 'django.contrib.auth.backends.ModelBackend' # - 'django.contrib.auth.backends.ModelBackend'

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -16,10 +16,11 @@ from rest_framework.response import Response
from company.models import SupplierPart from company.models import SupplierPart
from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool, DownloadFile
from InvenTree.api import AttachmentMixin from InvenTree.api import AttachmentMixin
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
from order.admin import POLineItemResource
import order.models as models import order.models as models
import order.serializers as serializers import order.serializers as serializers
from part.models import Part from part.models import Part
@ -370,6 +371,34 @@ class POLineItemList(generics.ListCreateAPIView):
return queryset return queryset
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
# Check if we wish to export the queried data to a file
export_format = request.query_params.get('export', None)
if export_format:
export_format = str(export_format).strip().lower()
if export_format in ['csv', 'tsv', 'xls', 'xlsx']:
dataset = POLineItemResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_PurchaseOrderData.{export_format}"
return DownloadFile(filedata, filename)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
filter_backends = [ filter_backends = [
rest_filters.DjangoFilterBackend, rest_filters.DjangoFilterBackend,
filters.SearchFilter, filters.SearchFilter,

View File

@ -152,32 +152,16 @@
{% if order.status == PurchaseOrderStatus.PENDING %} {% if order.status == PurchaseOrderStatus.PENDING %}
$('#new-po-line').click(function() { $('#new-po-line').click(function() {
var fields = poLineItemFields({
order: {{ order.pk }},
supplier: {{ order.supplier.pk }},
{% if order.supplier.currency %}
currency: '{{ order.supplier.currency }}',
{% endif %}
});
constructForm('{% url "api-po-line-list" %}', { constructForm('{% url "api-po-line-list" %}', {
fields: { fields: fields,
order: {
value: {{ order.pk }},
hidden: true,
},
part: {
filters: {
part_detail: true,
supplier_detail: true,
supplier: {{ order.supplier.pk }},
},
},
quantity: {},
reference: {},
purchase_price: {},
purchase_price_currency: {
{% if order.supplier.currency %}
value: '{{ order.supplier.currency }}',
{% endif %}
},
target_date: {},
destination: {},
notes: {},
},
method: 'POST', method: 'POST',
title: '{% trans "Add Line Item" %}', title: '{% trans "Add Line Item" %}',
onSuccess: function() { onSuccess: function() {

View File

@ -221,29 +221,19 @@
}, },
}); });
function reloadTable() {
$("#so-lines-table").bootstrapTable("refresh");
}
$("#new-so-line").click(function() { $("#new-so-line").click(function() {
var fields = soLineItemFields({
order: {{ order.pk }},
});
constructForm('{% url "api-so-line-list" %}', { constructForm('{% url "api-so-line-list" %}', {
fields: { fields: fields,
order: {
value: {{ order.pk }},
hidden: true,
},
part: {},
quantity: {},
reference: {},
sale_price: {},
sale_price_currency: {},
target_date: {},
notes: {},
},
method: 'POST', method: 'POST',
title: '{% trans "Add Line Item" %}', title: '{% trans "Add Line Item" %}',
onSuccess: reloadTable, onSuccess: function() {
$("#so-lines-table").bootstrapTable("refresh");
},
}); });
}); });

View File

@ -26,6 +26,8 @@ from djmoney.contrib.exchange.exceptions import MissingRate
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from part.admin import PartResource
from .models import Part, PartCategory, PartRelated from .models import Part, PartCategory, PartRelated
from .models import BomItem, BomItemSubstitute from .models import BomItem, BomItemSubstitute
from .models import PartParameter, PartParameterTemplate from .models import PartParameter, PartParameterTemplate
@ -43,6 +45,7 @@ from build.models import Build
from . import serializers as part_serializers from . import serializers as part_serializers
from InvenTree.helpers import str2bool, isNull, increment from InvenTree.helpers import str2bool, isNull, increment
from InvenTree.helpers import DownloadFile
from InvenTree.api import AttachmentMixin from InvenTree.api import AttachmentMixin
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus
@ -726,6 +729,22 @@ class PartList(generics.ListCreateAPIView):
queryset = self.filter_queryset(self.get_queryset()) queryset = self.filter_queryset(self.get_queryset())
# Check if we wish to export the queried data to a file.
# If so, skip pagination!
export_format = request.query_params.get('export', None)
if export_format:
export_format = str(export_format).strip().lower()
if export_format in ['csv', 'tsv', 'xls', 'xlsx']:
dataset = PartResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_Parts.{export_format}"
return DownloadFile(filedata, filename)
page = self.paginate_queryset(queryset) page = self.paginate_queryset(queryset)
if page is not None: if page is not None:

View File

@ -1908,6 +1908,9 @@ class Part(MPTTModel):
include_inherited = kwargs.get('include_inherited', False) include_inherited = kwargs.get('include_inherited', False)
# Should substitute parts be duplicated?
copy_substitutes = kwargs.get('copy_substitutes', True)
# Copy existing BOM items from another part # Copy existing BOM items from another part
# Note: Inherited BOM Items will *not* be duplicated!! # Note: Inherited BOM Items will *not* be duplicated!!
for bom_item in other.get_bom_items(include_inherited=include_inherited).all(): for bom_item in other.get_bom_items(include_inherited=include_inherited).all():
@ -1930,11 +1933,22 @@ class Part(MPTTModel):
if not bom_item.sub_part.check_add_to_bom(self, raise_error=raise_error): if not bom_item.sub_part.check_add_to_bom(self, raise_error=raise_error):
continue continue
# Obtain a list of direct substitute parts against this BomItem
substitutes = BomItemSubstitute.objects.filter(bom_item=bom_item)
# Construct a new BOM item # Construct a new BOM item
bom_item.part = self bom_item.part = self
bom_item.pk = None bom_item.pk = None
bom_item.save() bom_item.save()
bom_item.refresh_from_db()
if copy_substitutes:
for sub in substitutes:
# Duplicate the substitute (and point to the *new* BomItem object)
sub.pk = None
sub.bom_item = bom_item
sub.save()
@transaction.atomic @transaction.atomic
def copy_parameters_from(self, other, **kwargs): def copy_parameters_from(self, other, **kwargs):

View File

@ -656,6 +656,9 @@ class PartCopyBOMSerializer(serializers.Serializer):
fields = [ fields = [
'part', 'part',
'remove_existing', 'remove_existing',
'copy_substitutes',
'include_inherited',
'skip_invalid',
] ]
part = serializers.PrimaryKeyRelatedField( part = serializers.PrimaryKeyRelatedField(
@ -692,6 +695,12 @@ class PartCopyBOMSerializer(serializers.Serializer):
default=False, default=False,
) )
copy_substitutes = serializers.BooleanField(
label=_('Copy Substitute Parts'),
help_text=_('Copy substitute parts when duplicate BOM items'),
default=True,
)
def save(self): def save(self):
""" """
Actually duplicate the BOM Actually duplicate the BOM
@ -706,6 +715,7 @@ class PartCopyBOMSerializer(serializers.Serializer):
clear=data.get('remove_existing', True), clear=data.get('remove_existing', True),
skip_invalid=data.get('skip_invalid', False), skip_invalid=data.get('skip_invalid', False),
include_inherited=data.get('include_inherited', False), include_inherited=data.get('include_inherited', False),
copy_substitutes=data.get('copy_substitutes', True),
) )

View File

@ -153,9 +153,6 @@
<h4>{% trans "Parts" %}</h4> <h4>{% trans "Parts" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
<button type='button' class='btn btn-outline-secondary' id='part-export' title='{% trans "Export Part Data" %}'>
<span class='fas fa-file-download'></span> {% trans "Export" %}
</button>
{% if roles.part.add %} {% if roles.part.add %}
<button type='button' class='btn btn-success' id='part-create' title='{% trans "Create new part" %}'> <button type='button' class='btn btn-success' id='part-create' title='{% trans "Create new part" %}'>
<span class='fas fa-plus-circle'></span> {% trans "New Part" %} <span class='fas fa-plus-circle'></span> {% trans "New Part" %}
@ -290,13 +287,6 @@
}); });
}); });
$("#part-export").click(function() {
var url = "{% url 'part-export' %}?category={{ category.id }}";
location.href = url;
});
{% if roles.part.add %} {% if roles.part.add %}
$("#part-create").click(function() { $("#part-create").click(function() {

View File

@ -28,11 +28,6 @@
</div> </div>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
{% if part.is_template %}
<div class='alert alert-info alert-block'>
{% blocktrans with full_name=part.full_name%}Showing stock for all variants of <em>{{full_name}}</em>{% endblocktrans %}
</div>
{% endif %}
{% include "stock_table.html" %} {% include "stock_table.html" %}
</div> </div>
</div> </div>
@ -281,9 +276,7 @@
</button> </button>
<ul class='dropdown-menu' role='menu'> <ul class='dropdown-menu' role='menu'>
<li><a class='dropdown-item' href='#' id='bom-upload'><span class='fas fa-file-upload'></span> {% trans "Upload BOM" %}</a></li> <li><a class='dropdown-item' href='#' id='bom-upload'><span class='fas fa-file-upload'></span> {% trans "Upload BOM" %}</a></li>
{% if part.variant_of %}
<li><a class='dropdown-item' href='#' id='bom-duplicate'><span class='fas fa-clone'></span> {% trans "Copy BOM" %}</a></li> <li><a class='dropdown-item' href='#' id='bom-duplicate'><span class='fas fa-clone'></span> {% trans "Copy BOM" %}</a></li>
{% endif %}
<li><a class='dropdown-item' href='#' id='validate-bom'><span class='fas fa-clipboard-check icon-green'></span> {% trans "Validate BOM" %}</a></li> <li><a class='dropdown-item' href='#' id='validate-bom'><span class='fas fa-clipboard-check icon-green'></span> {% trans "Validate BOM" %}</a></li>
</ul> </ul>
</div> </div>
@ -831,14 +824,7 @@
], ],
url: "{% url 'api-stock-list' %}", url: "{% url 'api-stock-list' %}",
}); });
$("#stock-export").click(function() {
exportStock({
part: {{ part.pk }}
});
});
$('#item-create').click(function () { $('#item-create').click(function () {
createNewStockItem({ createNewStockItem({
data: { data: {

View File

@ -58,14 +58,6 @@ class PartListTest(PartViewTestCase):
self.assertIn('parts', keys) self.assertIn('parts', keys)
self.assertIn('user', keys) self.assertIn('user', keys)
def test_export(self):
""" Export part data to CSV """
response = self.client.get(reverse('part-export'), {'parts': '1,2,3,4,5,6,7,8,9,10'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
self.assertIn('streaming_content', dir(response))
class PartDetailTest(PartViewTestCase): class PartDetailTest(PartViewTestCase):

View File

@ -80,9 +80,6 @@ part_urls = [
# Download a BOM upload template # Download a BOM upload template
url(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='bom-upload-template'), url(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='bom-upload-template'),
# Export data for multiple parts
url(r'^export/', views.PartExport.as_view(), name='part-export'),
# Individual part using pk # Individual part using pk
url(r'^(?P<pk>\d+)/', include(part_detail_urls)), url(r'^(?P<pk>\d+)/', include(part_detail_urls)),

View File

@ -49,13 +49,11 @@ from . import settings as part_settings
from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat
from order.models import PurchaseOrderLineItem from order.models import PurchaseOrderLineItem
from .admin import PartResource
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.views import QRCodeView from InvenTree.views import QRCodeView
from InvenTree.views import InvenTreeRoleMixin from InvenTree.views import InvenTreeRoleMixin
from InvenTree.helpers import DownloadFile, str2bool from InvenTree.helpers import str2bool
class PartIndex(InvenTreeRoleMixin, ListView): class PartIndex(InvenTreeRoleMixin, ListView):
@ -709,69 +707,6 @@ class BomUpload(InvenTreeRoleMixin, DetailView):
template_name = 'part/upload_bom.html' template_name = 'part/upload_bom.html'
class PartExport(AjaxView):
""" Export a CSV file containing information on multiple parts """
role_required = 'part.view'
def get_parts(self, request):
""" Extract part list from the POST parameters.
Parts can be supplied as:
- Part category
- List of part PK values
"""
# Filter by part category
cat_id = request.GET.get('category', None)
part_list = None
if cat_id is not None:
try:
category = PartCategory.objects.get(pk=cat_id)
part_list = category.get_parts()
except (ValueError, PartCategory.DoesNotExist):
pass
# Backup - All parts
if part_list is None:
part_list = Part.objects.all()
# Also optionally filter by explicit list of part IDs
part_ids = request.GET.get('parts', '')
parts = []
for pk in part_ids.split(','):
try:
parts.append(int(pk))
except ValueError:
pass
if len(parts) > 0:
part_list = part_list.filter(pk__in=parts)
# Prefetch related fields to reduce DB hits
part_list = part_list.prefetch_related(
'category',
'used_in',
'builds',
'supplier_parts__purchase_order_line_items',
'stock_items__allocations',
)
return part_list
def get(self, request, *args, **kwargs):
parts = self.get_parts(request)
dataset = PartResource().export(queryset=parts)
csv = dataset.export('csv')
return DownloadFile(csv, 'InvenTree_Parts.csv')
class BomUploadTemplate(AjaxView): class BomUploadTemplate(AjaxView):
""" """
Provide a BOM upload template file for download. Provide a BOM upload template file for download.

View File

@ -30,6 +30,7 @@ from company.models import Company, SupplierPart
from company.serializers import CompanySerializer, SupplierPartSerializer from company.serializers import CompanySerializer, SupplierPartSerializer
from InvenTree.helpers import str2bool, isNull, extract_serial_numbers from InvenTree.helpers import str2bool, isNull, extract_serial_numbers
from InvenTree.helpers import DownloadFile
from InvenTree.api import AttachmentMixin from InvenTree.api import AttachmentMixin
from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.filters import InvenTreeOrderingFilter
@ -40,6 +41,7 @@ from order.serializers import POSerializer
from part.models import BomItem, Part, PartCategory from part.models import BomItem, Part, PartCategory
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
from stock.admin import StockItemResource
from stock.models import StockLocation, StockItem from stock.models import StockLocation, StockItem
from stock.models import StockItemTracking from stock.models import StockItemTracking
from stock.models import StockItemAttachment from stock.models import StockItemAttachment
@ -611,6 +613,27 @@ class StockList(generics.ListCreateAPIView):
queryset = self.filter_queryset(self.get_queryset()) queryset = self.filter_queryset(self.get_queryset())
params = request.query_params
# Check if we wish to export the queried data to a file.
# If so, skip pagination!
export_format = params.get('export', None)
if export_format:
export_format = str(export_format).strip().lower()
if export_format in ['csv', 'tsv', 'xls', 'xlsx']:
dataset = StockItemResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = 'InvenTree_Stocktake_{date}.{fmt}'.format(
date=datetime.now().strftime("%d-%b-%Y"),
fmt=export_format
)
return DownloadFile(filedata, filename)
page = self.paginate_queryset(queryset) page = self.paginate_queryset(queryset)
if page is not None: if page is not None:
@ -641,7 +664,7 @@ class StockList(generics.ListCreateAPIView):
supplier_part_ids.add(sp) supplier_part_ids.add(sp)
# Do we wish to include Part detail? # Do we wish to include Part detail?
if str2bool(request.query_params.get('part_detail', False)): if str2bool(params.get('part_detail', False)):
# Fetch only the required Part objects from the database # Fetch only the required Part objects from the database
parts = Part.objects.filter(pk__in=part_ids).prefetch_related( parts = Part.objects.filter(pk__in=part_ids).prefetch_related(
@ -659,7 +682,7 @@ class StockList(generics.ListCreateAPIView):
stock_item['part_detail'] = part_map.get(part_id, None) stock_item['part_detail'] = part_map.get(part_id, None)
# Do we wish to include SupplierPart detail? # Do we wish to include SupplierPart detail?
if str2bool(request.query_params.get('supplier_part_detail', False)): if str2bool(params.get('supplier_part_detail', False)):
supplier_parts = SupplierPart.objects.filter(pk__in=supplier_part_ids) supplier_parts = SupplierPart.objects.filter(pk__in=supplier_part_ids)
@ -673,7 +696,7 @@ class StockList(generics.ListCreateAPIView):
stock_item['supplier_part_detail'] = supplier_part_map.get(part_id, None) stock_item['supplier_part_detail'] = supplier_part_map.get(part_id, None)
# Do we wish to include StockLocation detail? # Do we wish to include StockLocation detail?
if str2bool(request.query_params.get('location_detail', False)): if str2bool(params.get('location_detail', False)):
# Fetch only the required StockLocation objects from the database # Fetch only the required StockLocation objects from the database
locations = StockLocation.objects.filter(pk__in=location_ids).prefetch_related( locations = StockLocation.objects.filter(pk__in=location_ids).prefetch_related(

View File

@ -239,15 +239,6 @@
}); });
{% endif %} {% endif %}
$("#stock-export").click(function() {
exportStock({
{% if location %}
location: {{ location.pk }}
{% endif %}
});
});
$('#location-create').click(function () { $('#location-create').click(function () {
createStockLocation({ createStockLocation({

View File

@ -6,9 +6,12 @@ Unit testing for the Stock API
from __future__ import unicode_literals from __future__ import unicode_literals
import os import os
import io
import tablib
from datetime import datetime, timedelta from datetime import datetime, timedelta
import django.http
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
@ -261,6 +264,56 @@ class StockItemListTest(StockAPITestCase):
self.assertEqual(len(response['results']), n) self.assertEqual(len(response['results']), n)
def export_data(self, filters=None):
if not filters:
filters = {}
filters['export'] = 'csv'
response = self.client.get(self.list_url, data=filters)
self.assertEqual(response.status_code, 200)
self.assertTrue(isinstance(response, django.http.response.StreamingHttpResponse))
file_object = io.StringIO(response.getvalue().decode('utf-8'))
dataset = tablib.Dataset().load(file_object, 'csv', headers=True)
return dataset
def test_export(self):
"""
Test exporting of Stock data via the API
"""
dataset = self.export_data({})
self.assertEqual(len(dataset), 20)
# Expected headers
headers = [
'part',
'customer',
'location',
'parent',
'quantity',
'status',
]
for h in headers:
self.assertIn(h, dataset.headers)
# Now, add a filter to the results
dataset = self.export_data({'location': 1})
self.assertEqual(len(dataset), 2)
dataset = self.export_data({'part': 25})
self.assertEqual(len(dataset), 8)
class StockItemTest(StockAPITestCase): class StockItemTest(StockAPITestCase):
""" """

View File

@ -47,8 +47,6 @@ stock_urls = [
url(r'^track/', include(stock_tracking_urls)), url(r'^track/', include(stock_tracking_urls)),
url(r'^export/?', views.StockExport.as_view(), name='stock-export'),
# Individual stock items # Individual stock items
url(r'^item/(?P<pk>\d+)/', include(stock_item_detail_urls)), url(r'^item/(?P<pk>\d+)/', include(stock_item_detail_urls)),

View File

@ -25,13 +25,13 @@ from InvenTree.views import QRCodeView
from InvenTree.views import InvenTreeRoleMixin from InvenTree.views import InvenTreeRoleMixin
from InvenTree.forms import ConfirmForm from InvenTree.forms import ConfirmForm
from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats from InvenTree.helpers import str2bool
from InvenTree.helpers import extract_serial_numbers from InvenTree.helpers import extract_serial_numbers
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from datetime import datetime, timedelta from datetime import datetime, timedelta
from company.models import Company, SupplierPart from company.models import SupplierPart
from part.models import Part from part.models import Part
from .models import StockItem, StockLocation, StockItemTracking from .models import StockItem, StockLocation, StockItemTracking
@ -39,8 +39,6 @@ import common.settings
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from users.models import Owner from users.models import Owner
from .admin import StockItemResource
from . import forms as StockForms from . import forms as StockForms
@ -380,95 +378,6 @@ class StockItemDeleteTestData(AjaxUpdateView):
return self.renderJsonResponse(request, form, data) return self.renderJsonResponse(request, form, data)
class StockExport(AjaxView):
""" Export stock data from a particular location.
Returns a file containing stock information for that location.
"""
model = StockItem
role_required = 'stock.view'
def get(self, request, *args, **kwargs):
export_format = request.GET.get('format', 'csv').lower()
# Check if a particular location was specified
loc_id = request.GET.get('location', None)
location = None
if loc_id:
try:
location = StockLocation.objects.get(pk=loc_id)
except (ValueError, StockLocation.DoesNotExist):
pass
# Check if a particular supplier was specified
sup_id = request.GET.get('supplier', None)
supplier = None
if sup_id:
try:
supplier = Company.objects.get(pk=sup_id)
except (ValueError, Company.DoesNotExist):
pass
# Check if a particular supplier_part was specified
sup_part_id = request.GET.get('supplier_part', None)
supplier_part = None
if sup_part_id:
try:
supplier_part = SupplierPart.objects.get(pk=sup_part_id)
except (ValueError, SupplierPart.DoesNotExist):
pass
# Check if a particular part was specified
part_id = request.GET.get('part', None)
part = None
if part_id:
try:
part = Part.objects.get(pk=part_id)
except (ValueError, Part.DoesNotExist):
pass
if export_format not in GetExportFormats():
export_format = 'csv'
filename = 'InvenTree_Stocktake_{date}.{fmt}'.format(
date=datetime.now().strftime("%d-%b-%Y"),
fmt=export_format
)
if location:
# Check if locations should be cascading
cascade = str2bool(request.GET.get('cascade', True))
stock_items = location.get_stock_items(cascade)
else:
stock_items = StockItem.objects.all()
if part:
stock_items = stock_items.filter(part=part)
if supplier:
stock_items = stock_items.filter(supplier_part__supplier=supplier)
if supplier_part:
stock_items = stock_items.filter(supplier_part=supplier_part)
# Filter out stock items that are not 'in stock'
stock_items = stock_items.filter(StockItem.IN_STOCK_FILTER)
# Pre-fetch related fields to reduce DB queries
stock_items = stock_items.prefetch_related('part', 'supplier_part__supplier', 'location', 'purchase_order', 'build')
dataset = StockItemResource().export(queryset=stock_items)
filedata = dataset.export(export_format)
return DownloadFile(filedata, filename)
class StockItemQRCode(QRCodeView): class StockItemQRCode(QRCodeView):
""" View for displaying a QR code for a StockItem object """ """ View for displaying a QR code for a StockItem object """

View File

@ -671,9 +671,7 @@ function loadBomTable(table, options={}) {
// Do we show part pricing in the BOM table? // Do we show part pricing in the BOM table?
var show_pricing = global_settings.PART_SHOW_PRICE_IN_BOM; var show_pricing = global_settings.PART_SHOW_PRICE_IN_BOM;
if (!show_pricing) { params.include_pricing = show_pricing == true;
params.include_pricing = false;
}
if (options.part_detail) { if (options.part_detail) {
params.part_detail = true; params.part_detail = true;
@ -989,32 +987,40 @@ function loadBomTable(table, options={}) {
// Function to request BOM data for sub-items // Function to request BOM data for sub-items
// This function may be called recursively for multi-level BOMs // This function may be called recursively for multi-level BOMs
function requestSubItems(bom_pk, part_pk) { function requestSubItems(bom_pk, part_pk, depth=0) {
// TODO: 2022-02-03 Currently, multi-level BOMs are not actually displayed. // Prevent multi-level recursion
const MAX_BOM_DEPTH = 25;
// Re-enable this function once multi-level display has been re-deployed if (depth >= MAX_BOM_DEPTH) {
return; console.log(`Maximum BOM depth (${MAX_BOM_DEPTH}) reached!`);
return;
}
inventreeGet( inventreeGet(
options.bom_url, options.bom_url,
{ {
part: part_pk, part: part_pk,
sub_part_detail: true, sub_part_detail: true,
include_pricing: show_pricing == true,
}, },
{ {
success: function(response) { success: function(response) {
// Add the returned sub-items to the table
for (var idx = 0; idx < response.length; idx++) { for (var idx = 0; idx < response.length; idx++) {
response[idx].parentId = bom_pk; response[idx].parentId = bom_pk;
if (response[idx].sub_part_detail.assembly) {
requestSubItems(response[idx].pk, response[idx].sub_part);
}
} }
table.bootstrapTable('append', response); table.bootstrapTable('append', response);
// Next, re-iterate and check if the new items also have sub items
response.forEach(function(bom_item) {
if (bom_item.sub_part_detail.assembly) {
requestSubItems(bom_item.pk, bom_item.sub_part, depth + 1);
}
});
table.treegrid('collapseAll'); table.treegrid('collapseAll');
}, },
error: function(xhr) { error: function(xhr) {
@ -1026,7 +1032,7 @@ function loadBomTable(table, options={}) {
} }
table.inventreeTable({ table.inventreeTable({
treeEnable: !options.editable, treeEnable: true,
rootParentId: parent_id, rootParentId: parent_id,
idField: 'pk', idField: 'pk',
uniqueId: 'pk', uniqueId: 'pk',
@ -1066,38 +1072,37 @@ function loadBomTable(table, options={}) {
url: options.bom_url, url: options.bom_url,
onPostBody: function() { onPostBody: function() {
if (!options.editable) { table.treegrid({
table.treegrid({ treeColumn: 1,
treeColumn: 0, onExpand: function() {
onExpand: function() { }
} });
});
} table.treegrid('collapseAll');
}, },
onLoadSuccess: function() { onLoadSuccess: function() {
if (options.editable) { if (options.editable) {
table.bootstrapTable('uncheckAll'); table.bootstrapTable('uncheckAll');
} else { }
var data = table.bootstrapTable('getData'); var data = table.bootstrapTable('getData');
for (var idx = 0; idx < data.length; idx++) { for (var idx = 0; idx < data.length; idx++) {
var row = data[idx]; var row = data[idx];
// If a row already has a parent ID set, it's already been updated! // If a row already has a parent ID set, it's already been updated!
if (row.parentId) { if (row.parentId) {
continue; continue;
} }
// Set the parent ID of the top-level rows // Set the parent ID of the top-level rows
row.parentId = parent_id; row.parentId = parent_id;
table.bootstrapTable('updateRow', idx, row, true); table.bootstrapTable('updateRow', idx, row, true);
if (row.sub_part_detail.assembly) { if (row.sub_part_detail.assembly) {
requestSubItems(row.pk, row.sub_part); requestSubItems(row.pk, row.sub_part);
}
} }
} }
}, },

View File

@ -256,7 +256,7 @@ function generateFilterInput(tableKey, filterKey) {
* @param {*} table - bootstrapTable element to update * @param {*} table - bootstrapTable element to update
* @param {*} target - name of target element on page * @param {*} target - name of target element on page
*/ */
function setupFilterList(tableKey, table, target) { function setupFilterList(tableKey, table, target, options={}) {
var addClicked = false; var addClicked = false;
@ -283,6 +283,11 @@ function setupFilterList(tableKey, table, target) {
var buttons = ''; var buttons = '';
// Add download button
if (options.download) {
buttons += `<button id='download-${tableKey}' title='{% trans "Download data" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-download'></span></button>`;
}
buttons += `<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-redo-alt'></span></button>`; buttons += `<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-outline-secondary filter-button'><span class='fas fa-redo-alt'></span></button>`;
// If there are filters defined for this table, add more buttons // If there are filters defined for this table, add more buttons
@ -295,7 +300,7 @@ function setupFilterList(tableKey, table, target) {
} }
element.html(` element.html(`
<div class='btn-group' role='group'> <div class='btn-group filter-group' role='group'>
${buttons} ${buttons}
</div> </div>
`); `);
@ -322,6 +327,13 @@ function setupFilterList(tableKey, table, target) {
$(table).bootstrapTable('refresh'); $(table).bootstrapTable('refresh');
}); });
// Add a callback for downloading table data
if (options.download) {
element.find(`#download-${tableKey}`).click(function() {
downloadTableData($(table));
});
}
// Add a callback for adding a new filter // Add a callback for adding a new filter
element.find(`#${add}`).click(function clicked() { element.find(`#${add}`).click(function clicked() {
@ -358,14 +370,14 @@ function setupFilterList(tableKey, table, target) {
reloadTableFilters(table, filters); reloadTableFilters(table, filters);
// Run this function again // Run this function again
setupFilterList(tableKey, table, target); setupFilterList(tableKey, table, target, options);
} }
}); });
} else { } else {
addClicked = false; addClicked = false;
setupFilterList(tableKey, table, target); setupFilterList(tableKey, table, target, options);
} }
}); });
@ -376,7 +388,7 @@ function setupFilterList(tableKey, table, target) {
reloadTableFilters(table, filters); reloadTableFilters(table, filters);
setupFilterList(tableKey, table, target); setupFilterList(tableKey, table, target, options);
}); });
// Add callback for deleting each filter // Add callback for deleting each filter
@ -390,7 +402,7 @@ function setupFilterList(tableKey, table, target) {
reloadTableFilters(table, filters); reloadTableFilters(table, filters);
// Run this function again! // Run this function again!
setupFilterList(tableKey, table, target); setupFilterList(tableKey, table, target, options);
}); });
} }

View File

@ -281,6 +281,65 @@ function createPurchaseOrder(options={}) {
} }
/* Construct a set of fields for the SalesOrderLineItem form */
function soLineItemFields(options={}) {
var fields = {
order: {
hidden: true,
},
part: {},
quantity: {},
reference: {},
sale_price: {},
sale_price_currency: {},
target_date: {},
notes: {},
};
if (options.order) {
fields.order.value = options.order;
}
return fields;
}
/* Construct a set of fields for the PurchaseOrderLineItem form */
function poLineItemFields(options={}) {
var fields = {
order: {
hidden: true,
},
part: {
filters: {
part_detail: true,
supplier_detail: true,
supplier: options.supplier,
}
},
quantity: {},
reference: {},
purchase_price: {},
purchase_price_currency: {},
target_date: {},
destination: {},
notes: {},
};
if (options.order) {
fields.order.value = options.order;
}
if (options.currency) {
fields.purchase_price_currency.value = options.currency;
}
return fields;
}
function removeOrderRowFromOrderWizard(e) { function removeOrderRowFromOrderWizard(e) {
/* Remove a part selection from an order form. */ /* Remove a part selection from an order form. */
@ -293,6 +352,7 @@ function removeOrderRowFromOrderWizard(e) {
$('#' + row).remove(); $('#' + row).remove();
} }
function newSupplierPartFromOrderWizard(e) { function newSupplierPartFromOrderWizard(e) {
/* Create a new supplier part directly from an order form. /* Create a new supplier part directly from an order form.
* Launches a secondary modal and (if successful), * Launches a secondary modal and (if successful),
@ -991,10 +1051,36 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
var target = options.filter_target || '#filter-list-purchase-order-lines'; var target = options.filter_target || '#filter-list-purchase-order-lines';
setupFilterList('purchaseorderlineitem', $(table), target); setupFilterList('purchaseorderlineitem', $(table), target, {download: true});
function setupCallbacks() { function setupCallbacks() {
if (options.allow_edit) { if (options.allow_edit) {
// Callback for "duplicate" button
$(table).find('.button-line-duplicate').click(function() {
var pk = $(this).attr('pk');
inventreeGet(`/api/order/po-line/${pk}/`, {}, {
success: function(data) {
var fields = poLineItemFields({
supplier: options.supplier,
});
constructForm('{% url "api-po-line-list" %}', {
method: 'POST',
fields: fields,
data: data,
title: '{% trans "Duplicate Line Item" %}',
onSuccess: function(response) {
$(table).bootstrapTable('refresh');
}
});
}
});
});
// Callback for "edit" button
$(table).find('.button-line-edit').click(function() { $(table).find('.button-line-edit').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
@ -1022,6 +1108,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
}); });
}); });
// Callback for "delete" button
$(table).find('.button-line-delete').click(function() { $(table).find('.button-line-delete').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
@ -1270,6 +1357,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
} }
if (options.allow_edit) { if (options.allow_edit) {
html += makeIconButton('fa-clone', 'button-line-duplicate', pk, '{% trans "Duplicate line item" %}');
html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}'); html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}'); html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
} }
@ -2449,6 +2537,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
html += makeIconButton('fa-dollar-sign icon-green', 'button-price', pk, '{% trans "Calculate price" %}'); html += makeIconButton('fa-dollar-sign icon-green', 'button-price', pk, '{% trans "Calculate price" %}');
} }
html += makeIconButton('fa-clone', 'button-duplicate', pk, '{% trans "Duplicate line item" %}');
html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}'); html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}');
var delete_disabled = false; var delete_disabled = false;
@ -2480,6 +2569,28 @@ function loadSalesOrderLineItemTable(table, options={}) {
// Configure callback functions once the table is loaded // Configure callback functions once the table is loaded
function setupCallbacks() { function setupCallbacks() {
// Callback for duplicating line items
$(table).find('.button-duplicate').click(function() {
var pk = $(this).attr('pk');
inventreeGet(`/api/order/so-line/${pk}/`, {}, {
success: function(data) {
var fields = soLineItemFields();
constructForm('{% url "api-so-line-list" %}', {
method: 'POST',
fields: fields,
data: data,
title: '{% trans "Duplicate Line Item" %}',
onSuccess: function(response) {
$(table).bootstrapTable('refresh');
}
});
}
});
});
// Callback for editing line items // Callback for editing line items
$(table).find('.button-edit').click(function() { $(table).find('.button-edit').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');

View File

@ -474,6 +474,7 @@ function duplicateBom(part_id, options={}) {
} }
}, },
include_inherited: {}, include_inherited: {},
copy_substitutes: {},
remove_existing: {}, remove_existing: {},
skip_invalid: {}, skip_invalid: {},
}, },
@ -1217,7 +1218,7 @@ function loadPartTable(table, url, options={}) {
filters[key] = params[key]; filters[key] = params[key];
} }
setupFilterList('parts', $(table), options.filterTarget || null); setupFilterList('parts', $(table), options.filterTarget, {download: true});
var columns = [ var columns = [
{ {

View File

@ -43,7 +43,6 @@
duplicateStockItem, duplicateStockItem,
editStockItem, editStockItem,
editStockLocation, editStockLocation,
exportStock,
findStockItemBySerialNumber, findStockItemBySerialNumber,
installStockItem, installStockItem,
loadInstalledInTable, loadInstalledInTable,
@ -506,49 +505,6 @@ function stockStatusCodes() {
} }
/*
* Export stock table
*/
function exportStock(params={}) {
constructFormBody({}, {
title: '{% trans "Export Stock" %}',
fields: {
format: {
label: '{% trans "Format" %}',
help_text: '{% trans "Select file format" %}',
required: true,
type: 'choice',
value: 'csv',
choices: exportFormatOptions(),
},
sublocations: {
label: '{% trans "Include Sublocations" %}',
help_text: '{% trans "Include stock items in sublocations" %}',
type: 'boolean',
value: 'true',
}
},
onSubmit: function(fields, form_options) {
var format = getFormFieldValue('format', fields['format'], form_options);
var cascade = getFormFieldValue('sublocations', fields['sublocations'], form_options);
// Hide the modal
$(form_options.modal).modal('hide');
var url = `{% url "stock-export" %}?format=${format}&cascade=${cascade}`;
for (var key in params) {
url += `&${key}=${params[key]}`;
}
location.href = url;
}
});
}
/** /**
* Assign multiple stock items to a customer * Assign multiple stock items to a customer
*/ */
@ -1615,7 +1571,7 @@ function loadStockTable(table, options) {
original[k] = params[k]; original[k] = params[k];
} }
setupFilterList(filterKey, table, filterTarget); setupFilterList(filterKey, table, filterTarget, {download: true});
// Override the default values, or add new ones // Override the default values, or add new ones
for (var key in params) { for (var key in params) {

View File

@ -7,6 +7,7 @@
/* exported /* exported
customGroupSorter, customGroupSorter,
downloadTableData,
reloadtable, reloadtable,
renderLink, renderLink,
reloadTableFilters, reloadTableFilters,
@ -21,6 +22,62 @@ function reloadtable(table) {
} }
/**
* Download data from a table, via the API.
* This requires a number of conditions to be met:
*
* - The API endpoint supports data download (on the server side)
* - The table is "flat" (does not support multi-level loading, etc)
* - The table has been loaded using the inventreeTable() function, not bootstrapTable()
* (Refer to the "reloadTableFilters" function to see why!)
*/
function downloadTableData(table, opts={}) {
// Extract table configuration options
var table_options = table.bootstrapTable('getOptions');
var url = table_options.url;
if (!url) {
console.log('Error: downloadTableData could not find "url" parameter.');
}
var query_params = table_options.query_params || {};
url += '?';
constructFormBody({}, {
title: opts.title || '{% trans "Export Table Data" %}',
fields: {
format: {
label: '{% trans "Format" %}',
help_text: '{% trans "Select File Format" %}',
required: true,
type: 'choice',
value: 'csv',
choices: exportFormatOptions(),
}
},
onSubmit: function(fields, form_options) {
var format = getFormFieldValue('format', fields['format'], form_options);
// Hide the modal
$(form_options.modal).modal('hide');
for (const [key, value] of Object.entries(query_params)) {
url += `${key}=${value}&`;
}
url += `export=${format}`;
location.href = url;
}
});
}
/** /**
* Render a URL for display * Render a URL for display
* @param {String} text * @param {String} text
@ -114,6 +171,10 @@ function reloadTableFilters(table, filters) {
} }
} }
// Store the total set of query params
// This is necessary for the "downloadTableData" function to work
options.query_params = params;
options.queryParams = function(tableParams) { options.queryParams = function(tableParams) {
return convertQueryParameters(tableParams, params); return convertQueryParameters(tableParams, params);
}; };
@ -221,7 +282,11 @@ $.fn.inventreeTable = function(options) {
// Extract query params // Extract query params
var filters = options.queryParams || options.filters || {}; var filters = options.queryParams || options.filters || {};
// Store the total set of query params
options.query_params = filters;
options.queryParams = function(params) { options.queryParams = function(params) {
// Update the query parameters callback with the *new* filters
return convertQueryParameters(params, filters); return convertQueryParameters(params, filters);
}; };

View File

@ -11,9 +11,6 @@
<div id='{{ prefix }}button-toolbar'> <div id='{{ prefix }}button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
<button class='btn btn-outline-secondary' id='stock-export' title='{% trans "Export Stock Information" %}'>
<span class='fas fa-download'></span>
</button>
{% if barcodes %} {% if barcodes %}
<!-- Barcode actions menu --> <!-- Barcode actions menu -->
<div class='btn-group' role='group'> <div class='btn-group' role='group'>