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.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 .config import get_setting
import InvenTree.tasks
@ -26,6 +31,9 @@ class InvenTreeConfig(AppConfig):
if not isInTestMode():
self.update_exchange_rates()
if canAppAccessDatabase() or settings.TESTING_ENV:
self.add_user_on_startup()
def remove_obsolete_tasks(self):
"""
Delete any obsolete scheduled tasks in the database
@ -138,3 +146,54 @@ class InvenTreeConfig(AppConfig):
update_exchange_rates()
except Exception as 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"
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+
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'

View File

@ -1,9 +1,12 @@
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
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.contrib.exchange.models import Rate, convert_money
@ -407,3 +410,46 @@ class TestStatus(TestCase):
def test_Importing(self):
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 class='panel-content'>
<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>
<table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table>
@ -282,12 +284,6 @@
filterKey: "companystock",
});
$("#stock-export").click(function() {
exportStock({
supplier: {{ company.id }}
});
});
{% if company.is_manufacturer %}
function reloadManufacturerPartTable() {

View File

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

View File

@ -154,6 +154,11 @@ static_root: '/home/inventree/data/static'
# Use environment variable INVENTREE_LOGIN_ATTEMPTS
#login_attempts: 5
# Add new user on first startup
#admin_user: admin
#admin_email: info@example.com
#admin_password: inventree
# Permit custom authentication backends
#authentication_backends:
# - '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 InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import str2bool
from InvenTree.helpers import str2bool, DownloadFile
from InvenTree.api import AttachmentMixin
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
from order.admin import POLineItemResource
import order.models as models
import order.serializers as serializers
from part.models import Part
@ -370,6 +371,34 @@ class POLineItemList(generics.ListCreateAPIView):
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 = [
rest_filters.DjangoFilterBackend,
filters.SearchFilter,

View File

@ -152,32 +152,16 @@
{% if order.status == PurchaseOrderStatus.PENDING %}
$('#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" %}', {
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: {},
},
fields: fields,
method: 'POST',
title: '{% trans "Add Line Item" %}',
onSuccess: function() {

View File

@ -221,29 +221,19 @@
},
});
function reloadTable() {
$("#so-lines-table").bootstrapTable("refresh");
}
$("#new-so-line").click(function() {
var fields = soLineItemFields({
order: {{ order.pk }},
});
constructForm('{% url "api-so-line-list" %}', {
fields: {
order: {
value: {{ order.pk }},
hidden: true,
},
part: {},
quantity: {},
reference: {},
sale_price: {},
sale_price_currency: {},
target_date: {},
notes: {},
},
fields: fields,
method: 'POST',
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 part.admin import PartResource
from .models import Part, PartCategory, PartRelated
from .models import BomItem, BomItemSubstitute
from .models import PartParameter, PartParameterTemplate
@ -43,6 +45,7 @@ from build.models import Build
from . import serializers as part_serializers
from InvenTree.helpers import str2bool, isNull, increment
from InvenTree.helpers import DownloadFile
from InvenTree.api import AttachmentMixin
from InvenTree.status_codes import BuildStatus
@ -726,6 +729,22 @@ class PartList(generics.ListCreateAPIView):
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)
if page is not None:

View File

@ -1908,6 +1908,9 @@ class Part(MPTTModel):
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
# Note: Inherited BOM Items will *not* be duplicated!!
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):
continue
# Obtain a list of direct substitute parts against this BomItem
substitutes = BomItemSubstitute.objects.filter(bom_item=bom_item)
# Construct a new BOM item
bom_item.part = self
bom_item.pk = None
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
def copy_parameters_from(self, other, **kwargs):

View File

@ -656,6 +656,9 @@ class PartCopyBOMSerializer(serializers.Serializer):
fields = [
'part',
'remove_existing',
'copy_substitutes',
'include_inherited',
'skip_invalid',
]
part = serializers.PrimaryKeyRelatedField(
@ -692,6 +695,12 @@ class PartCopyBOMSerializer(serializers.Serializer):
default=False,
)
copy_substitutes = serializers.BooleanField(
label=_('Copy Substitute Parts'),
help_text=_('Copy substitute parts when duplicate BOM items'),
default=True,
)
def save(self):
"""
Actually duplicate the BOM
@ -706,6 +715,7 @@ class PartCopyBOMSerializer(serializers.Serializer):
clear=data.get('remove_existing', True),
skip_invalid=data.get('skip_invalid', 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>
{% include "spacer.html" %}
<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 %}
<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" %}
@ -290,13 +287,6 @@
});
});
$("#part-export").click(function() {
var url = "{% url 'part-export' %}?category={{ category.id }}";
location.href = url;
});
{% if roles.part.add %}
$("#part-create").click(function() {

View File

@ -28,11 +28,6 @@
</div>
</div>
<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" %}
</div>
</div>
@ -281,9 +276,7 @@
</button>
<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>
{% if part.variant_of %}
<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>
</ul>
</div>
@ -831,14 +824,7 @@
],
url: "{% url 'api-stock-list' %}",
});
$("#stock-export").click(function() {
exportStock({
part: {{ part.pk }}
});
});
$('#item-create').click(function () {
createNewStockItem({
data: {

View File

@ -58,14 +58,6 @@ class PartListTest(PartViewTestCase):
self.assertIn('parts', 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):

View File

@ -80,9 +80,6 @@ part_urls = [
# Download a 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
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 order.models import PurchaseOrderLineItem
from .admin import PartResource
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.views import QRCodeView
from InvenTree.views import InvenTreeRoleMixin
from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.helpers import str2bool
class PartIndex(InvenTreeRoleMixin, ListView):
@ -709,69 +707,6 @@ class BomUpload(InvenTreeRoleMixin, DetailView):
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):
"""
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 InvenTree.helpers import str2bool, isNull, extract_serial_numbers
from InvenTree.helpers import DownloadFile
from InvenTree.api import AttachmentMixin
from InvenTree.filters import InvenTreeOrderingFilter
@ -40,6 +41,7 @@ from order.serializers import POSerializer
from part.models import BomItem, Part, PartCategory
from part.serializers import PartBriefSerializer
from stock.admin import StockItemResource
from stock.models import StockLocation, StockItem
from stock.models import StockItemTracking
from stock.models import StockItemAttachment
@ -611,6 +613,27 @@ class StockList(generics.ListCreateAPIView):
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)
if page is not None:
@ -641,7 +664,7 @@ class StockList(generics.ListCreateAPIView):
supplier_part_ids.add(sp)
# 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
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)
# 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)
@ -673,7 +696,7 @@ class StockList(generics.ListCreateAPIView):
stock_item['supplier_part_detail'] = supplier_part_map.get(part_id, None)
# 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
locations = StockLocation.objects.filter(pk__in=location_ids).prefetch_related(

View File

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

View File

@ -6,9 +6,12 @@ Unit testing for the Stock API
from __future__ import unicode_literals
import os
import io
import tablib
from datetime import datetime, timedelta
import django.http
from django.urls import reverse
from rest_framework import status
@ -261,6 +264,56 @@ class StockItemListTest(StockAPITestCase):
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):
"""

View File

@ -47,8 +47,6 @@ stock_urls = [
url(r'^track/', include(stock_tracking_urls)),
url(r'^export/?', views.StockExport.as_view(), name='stock-export'),
# Individual stock items
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.forms import ConfirmForm
from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats
from InvenTree.helpers import str2bool
from InvenTree.helpers import extract_serial_numbers
from decimal import Decimal, InvalidOperation
from datetime import datetime, timedelta
from company.models import Company, SupplierPart
from company.models import SupplierPart
from part.models import Part
from .models import StockItem, StockLocation, StockItemTracking
@ -39,8 +39,6 @@ import common.settings
from common.models import InvenTreeSetting
from users.models import Owner
from .admin import StockItemResource
from . import forms as StockForms
@ -380,95 +378,6 @@ class StockItemDeleteTestData(AjaxUpdateView):
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):
""" 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?
var show_pricing = global_settings.PART_SHOW_PRICE_IN_BOM;
if (!show_pricing) {
params.include_pricing = false;
}
params.include_pricing = show_pricing == true;
if (options.part_detail) {
params.part_detail = true;
@ -989,32 +987,40 @@ function loadBomTable(table, options={}) {
// Function to request BOM data for sub-items
// 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
return;
if (depth >= MAX_BOM_DEPTH) {
console.log(`Maximum BOM depth (${MAX_BOM_DEPTH}) reached!`);
return;
}
inventreeGet(
options.bom_url,
{
part: part_pk,
sub_part_detail: true,
include_pricing: show_pricing == true,
},
{
success: function(response) {
// Add the returned sub-items to the table
for (var idx = 0; idx < response.length; idx++) {
response[idx].parentId = bom_pk;
if (response[idx].sub_part_detail.assembly) {
requestSubItems(response[idx].pk, response[idx].sub_part);
}
}
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');
},
error: function(xhr) {
@ -1026,7 +1032,7 @@ function loadBomTable(table, options={}) {
}
table.inventreeTable({
treeEnable: !options.editable,
treeEnable: true,
rootParentId: parent_id,
idField: 'pk',
uniqueId: 'pk',
@ -1066,38 +1072,37 @@ function loadBomTable(table, options={}) {
url: options.bom_url,
onPostBody: function() {
if (!options.editable) {
table.treegrid({
treeColumn: 0,
onExpand: function() {
}
});
}
table.treegrid({
treeColumn: 1,
onExpand: function() {
}
});
table.treegrid('collapseAll');
},
onLoadSuccess: function() {
if (options.editable) {
table.bootstrapTable('uncheckAll');
} else {
}
var data = table.bootstrapTable('getData');
var data = table.bootstrapTable('getData');
for (var idx = 0; idx < data.length; idx++) {
var row = data[idx];
for (var idx = 0; idx < data.length; idx++) {
var row = data[idx];
// If a row already has a parent ID set, it's already been updated!
if (row.parentId) {
continue;
}
// If a row already has a parent ID set, it's already been updated!
if (row.parentId) {
continue;
}
// Set the parent ID of the top-level rows
row.parentId = parent_id;
// Set the parent ID of the top-level rows
row.parentId = parent_id;
table.bootstrapTable('updateRow', idx, row, true);
table.bootstrapTable('updateRow', idx, row, true);
if (row.sub_part_detail.assembly) {
requestSubItems(row.pk, row.sub_part);
}
if (row.sub_part_detail.assembly) {
requestSubItems(row.pk, row.sub_part);
}
}
},

View File

@ -256,7 +256,7 @@ function generateFilterInput(tableKey, filterKey) {
* @param {*} table - bootstrapTable element to update
* @param {*} target - name of target element on page
*/
function setupFilterList(tableKey, table, target) {
function setupFilterList(tableKey, table, target, options={}) {
var addClicked = false;
@ -283,6 +283,11 @@ function setupFilterList(tableKey, table, target) {
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>`;
// If there are filters defined for this table, add more buttons
@ -295,7 +300,7 @@ function setupFilterList(tableKey, table, target) {
}
element.html(`
<div class='btn-group' role='group'>
<div class='btn-group filter-group' role='group'>
${buttons}
</div>
`);
@ -322,6 +327,13 @@ function setupFilterList(tableKey, table, target) {
$(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
element.find(`#${add}`).click(function clicked() {
@ -358,14 +370,14 @@ function setupFilterList(tableKey, table, target) {
reloadTableFilters(table, filters);
// Run this function again
setupFilterList(tableKey, table, target);
setupFilterList(tableKey, table, target, options);
}
});
} else {
addClicked = false;
setupFilterList(tableKey, table, target);
setupFilterList(tableKey, table, target, options);
}
});
@ -376,7 +388,7 @@ function setupFilterList(tableKey, table, target) {
reloadTableFilters(table, filters);
setupFilterList(tableKey, table, target);
setupFilterList(tableKey, table, target, options);
});
// Add callback for deleting each filter
@ -390,7 +402,7 @@ function setupFilterList(tableKey, table, target) {
reloadTableFilters(table, filters);
// 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) {
/* Remove a part selection from an order form. */
@ -293,6 +352,7 @@ function removeOrderRowFromOrderWizard(e) {
$('#' + row).remove();
}
function newSupplierPartFromOrderWizard(e) {
/* Create a new supplier part directly from an order form.
* 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';
setupFilterList('purchaseorderlineitem', $(table), target);
setupFilterList('purchaseorderlineitem', $(table), target, {download: true});
function setupCallbacks() {
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() {
var pk = $(this).attr('pk');
@ -1022,6 +1108,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
});
});
// Callback for "delete" button
$(table).find('.button-line-delete').click(function() {
var pk = $(this).attr('pk');
@ -1270,6 +1357,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
}
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-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-clone', 'button-duplicate', pk, '{% trans "Duplicate line item" %}');
html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}');
var delete_disabled = false;
@ -2480,6 +2569,28 @@ function loadSalesOrderLineItemTable(table, options={}) {
// Configure callback functions once the table is loaded
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
$(table).find('.button-edit').click(function() {
var pk = $(this).attr('pk');

View File

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

View File

@ -43,7 +43,6 @@
duplicateStockItem,
editStockItem,
editStockLocation,
exportStock,
findStockItemBySerialNumber,
installStockItem,
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
*/
@ -1615,7 +1571,7 @@ function loadStockTable(table, options) {
original[k] = params[k];
}
setupFilterList(filterKey, table, filterTarget);
setupFilterList(filterKey, table, filterTarget, {download: true});
// Override the default values, or add new ones
for (var key in params) {

View File

@ -7,6 +7,7 @@
/* exported
customGroupSorter,
downloadTableData,
reloadtable,
renderLink,
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
* @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) {
return convertQueryParameters(tableParams, params);
};
@ -221,7 +282,11 @@ $.fn.inventreeTable = function(options) {
// Extract query params
var filters = options.queryParams || options.filters || {};
// Store the total set of query params
options.query_params = filters;
options.queryParams = function(params) {
// Update the query parameters callback with the *new* filters
return convertQueryParameters(params, filters);
};

View File

@ -11,9 +11,6 @@
<div id='{{ prefix }}button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<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 %}
<!-- Barcode actions menu -->
<div class='btn-group' role='group'>