mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into matmair/issue2279
This commit is contained in:
commit
6071f6545b
@ -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
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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() {
|
||||
|
@ -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: {
|
||||
|
@ -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
@ -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,
|
||||
|
@ -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() {
|
||||
|
@ -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");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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):
|
||||
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
@ -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() {
|
||||
|
||||
|
@ -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: {
|
||||
|
@ -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):
|
||||
|
||||
|
@ -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)),
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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(
|
||||
|
@ -239,15 +239,6 @@
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
$("#stock-export").click(function() {
|
||||
|
||||
exportStock({
|
||||
{% if location %}
|
||||
location: {{ location.pk }}
|
||||
{% endif %}
|
||||
});
|
||||
});
|
||||
|
||||
$('#location-create').click(function () {
|
||||
|
||||
createStockLocation({
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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)),
|
||||
|
||||
|
@ -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 """
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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');
|
||||
|
@ -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 = [
|
||||
{
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
};
|
||||
|
||||
|
@ -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'>
|
||||
|
Loading…
Reference in New Issue
Block a user