Merge branch 'master' of https://github.com/inventree/InvenTree into fr-1421-sso

This commit is contained in:
Matthias 2021-10-07 18:12:25 +02:00
commit fe7ab40b48
No known key found for this signature in database
GPG Key ID: F50EF5741D33E076
34 changed files with 1041 additions and 641 deletions

View File

@ -1,4 +1,5 @@
# Build and push latest docker image on push to master branch
# Build and push docker image on push to 'stable' branch
# Docker build will be uploaded to dockerhub with the 'inventree:stable' tag
name: Docker Build

View File

@ -1,4 +1,5 @@
# Publish docker images to dockerhub
# Publish docker images to dockerhub on a tagged release
# Docker build will be uploaded to dockerhub with the 'invetree:<tag>' tag
name: Docker Publish

37
.github/workflows/docker_test.yaml vendored Normal file
View File

@ -0,0 +1,37 @@
# Test that the InvenTree docker image compiles correctly
# This CI action runs on pushes to either the master or stable branches
# 1. Build the development docker image (as per the documentation)
# 2. Install requied python libs into the docker container
# 3. Launch the container
# 4. Check that the API endpoint is available
name: Docker Test
on:
push:
branches:
- 'master'
- 'stable'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Build Docker Image
run: |
cd docker
docker-compose -f docker-compose.dev.yml build
docker-compose -f docker-compose.dev.yml run inventree-dev-server invoke update
docker-compose -f docker-compose.dev.yml up -d
- name: Sleepy Time
run: sleep 60
- name: Test API
run: |
pip install requests
python3 ci/check_api_endpoint.py

View File

@ -63,6 +63,12 @@ class InvenTreeConfig(AppConfig):
schedule_type=Schedule.DAILY,
)
# Delete old error messages
InvenTree.tasks.schedule_task(
'InvenTree.tasks.delete_old_error_logs',
schedule_type=Schedule.DAILY,
)
# Delete "old" stock items
InvenTree.tasks.schedule_task(
'stock.tasks.delete_old_stock_items',

View File

@ -242,6 +242,14 @@
border-color: var(--label-red);
}
.label-form {
margin: 2px;
padding: 3px;
padding-left: 10px;
padding-right: 10px;
border-radius: 5px;
}
.label-red {
background: var(--label-red);
}

View File

@ -156,7 +156,34 @@ def delete_successful_tasks():
started__lte=threshold
)
results.delete()
if results.count() > 0:
logger.info(f"Deleting {results.count()} successful task records")
results.delete()
def delete_old_error_logs():
"""
Delete old error logs from the server
"""
try:
from error_report.models import Error
# Delete any error logs more than 30 days old
threshold = timezone.now() - timedelta(days=30)
errors = Error.objects.filter(
when__lte=threshold,
)
if errors.count() > 0:
logger.info(f"Deleting {errors.count()} old error logs")
errors.delete()
except AppRegistryNotReady:
# Apps not yet loaded
logger.info("Could not perform 'delete_old_error_logs' - App registry not ready")
return
def check_for_updates():
@ -215,7 +242,7 @@ def delete_expired_sessions():
# Delete any sessions that expired more than a day ago
expired = Session.objects.filter(expire_date__lt=timezone.now() - timedelta(days=1))
if True or expired.count() > 0:
if expired.count() > 0:
logger.info(f"Deleting {expired.count()} expired sessions.")
expired.delete()
@ -247,15 +274,15 @@ def update_exchange_rates():
pass
except:
# Some other error
print("Database not ready")
logger.warning("update_exchange_rates: Database not ready")
return
backend = InvenTreeExchange()
print(f"Updating exchange rates from {backend.url}")
logger.info(f"Updating exchange rates from {backend.url}")
base = currency_code_default()
print(f"Using base currency '{base}'")
logger.info(f"Using base currency '{base}'")
backend.update_rates(base_currency=base)

View File

@ -10,12 +10,16 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev"
INVENTREE_API_VERSION = 14
INVENTREE_API_VERSION = 15
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v14 -> 2021-20-05
v15 -> 2021-10-06
- Adds detail endpoint for SalesOrderAllocation model
- Allows use of the API forms interface for adjusting SalesOrderAllocation objects
v14 -> 2021-10-05
- Stock adjustment actions API is improved, using native DRF serializer support
- However adjustment actions now only support 'pk' as a lookup field
@ -104,7 +108,7 @@ def inventreeDocsVersion():
Return the version string matching the latest documentation.
Development -> "latest"
Release -> "major.minor"
Release -> "major.minor.sub" e.g. "0.5.2"
"""
@ -113,7 +117,7 @@ def inventreeDocsVersion():
else:
major, minor, patch = inventreeVersionTuple()
return f"{major}.{minor}"
return f"{major}.{minor}.{patch}"
def isInvenTreeUpToDate():

View File

@ -648,14 +648,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool,
},
# TODO: Remove this setting in future, new API forms make this not useful
'PART_SHOW_QUANTITY_IN_FORMS': {
'name': _('Show Quantity in Forms'),
'description': _('Display available part quantity in some forms'),
'default': True,
'validator': bool,
},
'PART_SHOW_IMPORT': {
'name': _('Show Import in Views'),
'description': _('Display the import wizard in some part views'),
@ -1014,6 +1006,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'default': 10,
'validator': [int, MinValueValidator(1)]
},
'PART_SHOW_QUANTITY_IN_FORMS': {
'name': _('Show Quantity in Forms'),
'description': _('Display available part quantity in some forms'),
'default': True,
'validator': bool,
},
}
class Meta:

View File

@ -158,6 +158,12 @@
function reloadImage(data) {
if (data.image) {
$('#company-image').attr('src', data.image);
// Reset the "modal image" view
$('#company-image').click(function() {
showModalImage(data.image);
});
} else {
location.reload();
}

View File

@ -77,6 +77,14 @@ class POLineItemResource(ModelResource):
class SOLineItemResource(ModelResource):
""" Class for managing import / export of SOLineItem data """
part_name = Field(attribute='part__name', readonly=True)
IPN = Field(attribute='part__IPN', readonly=True)
description = Field(attribute='part__description', readonly=True)
fulfilled = Field(attribute='fulfilled_quantity', readonly=True)
class Meta:
model = SalesOrderLineItem
skip_unchanged = True

View File

@ -631,6 +631,15 @@ class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = SOLineItemSerializer
class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for detali view of a SalesOrderAllocation object
"""
queryset = SalesOrderAllocation.objects.all()
serializer_class = SalesOrderAllocationSerializer
class SOAllocationList(generics.ListCreateAPIView):
"""
API endpoint for listing SalesOrderAllocation objects
@ -743,8 +752,10 @@ order_api_urls = [
])),
# API endpoints for purchase order line items
url(r'^po-line/(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'),
url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'),
url(r'^po-line/', include([
url(r'^(?P<pk>\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'),
url(r'^.*$', POLineItemList.as_view(), name='api-po-line-list'),
])),
# API endpoints for sales ordesr
url(r'^so/', include([
@ -764,9 +775,8 @@ order_api_urls = [
])),
# API endpoints for sales order allocations
url(r'^so-allocation', include([
# List all sales order allocations
url(r'^so-allocation/', include([
url(r'^(?P<pk>\d+)/$', SOAllocationDetail.as_view(), name='api-so-allocation-detail'),
url(r'^.*$', SOAllocationList.as_view(), name='api-so-allocation-list'),
])),
]

View File

@ -115,23 +115,6 @@ class AllocateSerialsToSalesOrderForm(forms.Form):
]
class CreateSalesOrderAllocationForm(HelperForm):
"""
Form for creating a SalesOrderAllocation item.
"""
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
class Meta:
model = SalesOrderAllocation
fields = [
'line',
'item',
'quantity',
]
class EditSalesOrderAllocationForm(HelperForm):
"""
Form for editing a SalesOrderAllocation item

View File

@ -840,7 +840,13 @@ class SalesOrderLineItem(OrderLineItem):
def get_api_url():
return reverse('api-so-line-list')
order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='lines', verbose_name=_('Order'), help_text=_('Sales Order'))
order = models.ForeignKey(
SalesOrder,
on_delete=models.CASCADE,
related_name='lines',
verbose_name=_('Order'),
help_text=_('Sales Order')
)
part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, verbose_name=_('Part'), help_text=_('Part'), limit_choices_to={'salable': True})
@ -954,7 +960,11 @@ class SalesOrderAllocation(models.Model):
if len(errors) > 0:
raise ValidationError(errors)
line = models.ForeignKey(SalesOrderLineItem, on_delete=models.CASCADE, verbose_name=_('Line'), related_name='allocations')
line = models.ForeignKey(
SalesOrderLineItem,
on_delete=models.CASCADE,
verbose_name=_('Line'),
related_name='allocations')
item = models.ForeignKey(
'stock.StockItem',

View File

@ -478,7 +478,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True)
order = serializers.PrimaryKeyRelatedField(source='line.order', many=False, read_only=True)
serial = serializers.CharField(source='get_serial', read_only=True)
quantity = serializers.FloatField(read_only=True)
quantity = serializers.FloatField(read_only=False)
location = serializers.PrimaryKeyRelatedField(source='item.location', many=False, read_only=True)
# Extra detail fields
@ -549,7 +549,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer(many=True, read_only=True)
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
quantity = serializers.FloatField()

View File

@ -39,6 +39,9 @@ src="{% static 'img/blank_image.png' %}"
<button type='button' class='btn btn-default' id='print-order-report' title='{% trans "Print" %}'>
<span class='fas fa-print'></span>
</button>
<button type='button' class='btn btn-default' id='export-order' title='{% trans "Export order to file" %}'>
<span class='fas fa-file-download'></span>
</button>
{% if roles.purchase_order.change %}
<button type='button' class='btn btn-default' id='edit-order' title='{% trans "Edit order information" %}'>
<span class='fas fa-edit icon-green'></span>
@ -61,9 +64,6 @@ src="{% static 'img/blank_image.png' %}"
</button>
{% endif %}
{% endif %}
<button type='button' class='btn btn-default' id='export-order' title='{% trans "Export order to file" %}'>
<span class='fas fa-file-download'></span>
</button>
</div>
</div>
{% endblock %}
@ -224,7 +224,7 @@ $("#cancel-order").click(function() {
});
$("#export-order").click(function() {
location.href = "{% url 'po-export' order.id %}";
exportOrder('{% url "po-export" order.id %}');
});

View File

@ -50,6 +50,9 @@ src="{% static 'img/blank_image.png' %}"
<button type='button' class='btn btn-default' id='print-order-report' title='{% trans "Print" %}'>
<span class='fas fa-print'></span>
</button>
<button type='button' class='btn btn-default' id='export-order' title='{% trans "Export order to file" %}'>
<span class='fas fa-file-download'></span>
</button>
{% if roles.sales_order.change %}
<button type='button' class='btn btn-default' id='edit-order' title='{% trans "Edit order information" %}'>
<span class='fas fa-edit icon-green'></span>
@ -63,9 +66,11 @@ src="{% static 'img/blank_image.png' %}"
</button>
{% endif %}
{% endif %}
<!--
<button type='button' disabled='' class='btn btn-default' id='packing-list' title='{% trans "Packing List" %}'>
<span class='fas fa-clipboard-list'></span>
</button>
-->
</div>
</div>
{% endblock %}
@ -196,4 +201,8 @@ $('#print-order-report').click(function() {
printSalesOrderReports([{{ order.pk }}]);
});
$('#export-order').click(function() {
exportOrder('{% url "so-export" order.id %}');
});
{% endblock %}

View File

@ -158,467 +158,38 @@
$("#so-lines-table").bootstrapTable("refresh");
}
$("#new-so-line").click(function() {
$("#new-so-line").click(function() {
constructForm('{% url "api-so-line-list" %}', {
fields: {
order: {
value: {{ order.pk }},
hidden: true,
},
part: {},
quantity: {},
reference: {},
sale_price: {},
sale_price_currency: {},
notes: {},
},
method: 'POST',
title: '{% trans "Add Line Item" %}',
onSuccess: reloadTable,
});
});
{% if order.status == SalesOrderStatus.PENDING %}
function showAllocationSubTable(index, row, element) {
// Construct a table showing stock items which have been allocated against this line item
var html = `<div class='sub-table'><table class='table table-striped table-condensed' id='allocation-table-${row.pk}'></table></div>`;
element.html(html);
var lineItem = row;
var table = $(`#allocation-table-${row.pk}`);
table.bootstrapTable({
data: row.allocations,
showHeader: false,
columns: [
{
width: '50%',
field: 'allocated',
title: '{% trans "Quantity" %}',
formatter: function(value, row, index, field) {
var text = '';
if (row.serial != null && row.quantity == 1) {
text = `{% trans "Serial Number" %}: ${row.serial}`;
} else {
text = `{% trans "Quantity" %}: ${row.quantity}`;
}
return renderLink(text, `/stock/item/${row.item}/`);
},
},
{
field: 'location',
title: 'Location',
formatter: function(value, row, index, field) {
return renderLink(row.location_path, `/stock/location/${row.location}/`);
},
},
{
field: 'po'
},
{
field: 'buttons',
title: '{% trans "Actions" %}',
formatter: function(value, row, index, field) {
var html = "<div class='btn-group float-right' role='group'>";
var pk = row.pk;
{% if order.status == SalesOrderStatus.PENDING %}
html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
{% endif %}
html += "</div>";
return html;
},
},
],
});
table.find(".button-allocation-edit").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/allocation/${pk}/edit/`, {
success: reloadTable,
});
});
table.find(".button-allocation-delete").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/allocation/${pk}/delete/`, {
success: reloadTable,
});
});
}
{% endif %}
function showFulfilledSubTable(index, row, element) {
// Construct a table showing stock items which have been fulfilled against this line item
var id = `fulfilled-table-${row.pk}`;
var html = `<div class='sub-table'><table class='table table-striped table-condensed' id='${id}'></table></div>`;
element.html(html);
var lineItem = row;
$(`#${id}`).bootstrapTable({
url: "{% url 'api-stock-list' %}",
queryParams: {
part: row.part,
sales_order: {{ order.id }},
},
showHeader: false,
columns: [
{
field: 'pk',
visible: false,
},
{
field: 'stock',
formatter: function(value, row) {
var text = '';
if (row.serial && row.quantity == 1) {
text = `{% trans "Serial Number" %}: ${row.serial}`;
} else {
text = `{% trans "Quantity" %}: ${row.quantity}`;
}
return renderLink(text, `/stock/item/${row.pk}/`);
},
},
{
field: 'po'
},
],
});
}
$("#so-lines-table").inventreeTable({
formatNoMatches: function() { return "{% trans 'No matching line items' %}"; },
queryParams: {
order: {{ order.id }},
part_detail: true,
allocations: true,
},
sidePagination: 'server',
uniqueId: 'pk',
url: "{% url 'api-so-line-list' %}",
onPostBody: setupCallbacks,
{% if order.status == SalesOrderStatus.PENDING or order.status == SalesOrderStatus.SHIPPED %}
detailViewByClick: true,
detailView: true,
detailFilter: function(index, row) {
{% if order.status == SalesOrderStatus.PENDING %}
return row.allocated > 0;
{% else %}
return row.fulfilled > 0;
{% endif %}
},
{% if order.status == SalesOrderStatus.PENDING %}
detailFormatter: showAllocationSubTable,
{% else %}
detailFormatter: showFulfilledSubTable,
{% endif %}
{% endif %}
showFooter: true,
columns: [
{
field: 'pk',
title: '{% trans "ID" %}',
visible: false,
switchable: false,
},
{
sortable: true,
sortName: 'part__name',
field: 'part',
title: '{% trans "Part" %}',
formatter: function(value, row, index, field) {
if (row.part) {
return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${value}/`);
} else {
return '-';
}
},
footerFormatter: function() {
return '{% trans "Total" %}'
},
},
{
sortable: true,
field: 'reference',
title: '{% trans "Reference" %}'
},
{
sortable: true,
field: 'quantity',
title: '{% trans "Quantity" %}',
footerFormatter: function(data) {
return data.map(function (row) {
return +row['quantity']
}).reduce(function (sum, i) {
return sum + i
}, 0)
},
},
{
sortable: true,
field: 'sale_price',
title: '{% trans "Unit Price" %}',
formatter: function(value, row) {
return row.sale_price_string || row.sale_price;
}
},
{
sortable: true,
title: '{% trans "Total price" %}',
formatter: function(value, row) {
var total = row.sale_price * row.quantity;
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: row.sale_price_currency});
return formatter.format(total)
},
footerFormatter: function(data) {
var total = data.map(function (row) {
return +row['sale_price']*row['quantity']
}).reduce(function (sum, i) {
return sum + i
}, 0)
var currency = (data.slice(-1)[0] && data.slice(-1)[0].sale_price_currency) || 'USD';
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: currency});
return formatter.format(total)
}
},
{
field: 'allocated',
{% if order.status == SalesOrderStatus.PENDING %}
title: '{% trans "Allocated" %}',
{% else %}
title: '{% trans "Fulfilled" %}',
{% endif %}
formatter: function(value, row, index, field) {
{% if order.status == SalesOrderStatus.PENDING %}
var quantity = row.allocated;
{% else %}
var quantity = row.fulfilled;
{% endif %}
return makeProgressBar(quantity, row.quantity, {
id: `order-line-progress-${row.pk}`,
});
},
sorter: function(valA, valB, rowA, rowB) {
{% if order.status == SalesOrderStatus.PENDING %}
var A = rowA.allocated;
var B = rowB.allocated;
{% else %}
var A = rowA.fulfilled;
var B = rowB.fulfilled;
{% endif %}
if (A == 0 && B == 0) {
return (rowA.quantity > rowB.quantity) ? 1 : -1;
}
var progressA = parseFloat(A) / rowA.quantity;
var progressB = parseFloat(B) / rowB.quantity;
return (progressA < progressB) ? 1 : -1;
}
},
{
field: 'notes',
title: '{% trans "Notes" %}',
},
{
field: 'po',
title: '{% trans "PO" %}',
formatter: function(value, row, index, field) {
var po_name = "";
if (row.allocated) {
row.allocations.forEach(function(allocation) {
if (allocation.po != po_name) {
if (po_name) {
po_name = "-";
} else {
po_name = allocation.po
}
}
})
}
return `<div>` + po_name + `</div>`;
}
},
{% if order.status == SalesOrderStatus.PENDING %}
{
field: 'buttons',
formatter: function(value, row, index, field) {
var html = `<div class='btn-group float-right' role='group'>`;
var pk = row.pk;
if (row.part) {
var part = row.part_detail;
if (part.trackable) {
html += makeIconButton('fa-hashtag icon-green', 'button-add-by-sn', pk, '{% trans "Allocate serial numbers" %}');
}
html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', pk, '{% trans "Allocate stock" %}');
if (part.purchaseable) {
html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}');
}
if (part.assembly) {
html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build stock" %}');
}
html += makeIconButton('fa-dollar-sign icon-green', 'button-price', pk, '{% trans "Calculate price" %}');
}
html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line item " %}');
html += `</div>`;
return html;
}
},
{% endif %}
],
});
function setupCallbacks() {
var table = $("#so-lines-table");
// Set up callbacks for the row buttons
table.find(".button-edit").click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/order/so-line/${pk}/`, {
constructForm('{% url "api-so-line-list" %}', {
fields: {
order: {
value: {{ order.pk }},
hidden: true,
},
part: {},
quantity: {},
reference: {},
sale_price: {},
sale_price_currency: {},
notes: {},
},
title: '{% trans "Edit Line Item" %}',
method: 'POST',
title: '{% trans "Add Line Item" %}',
onSuccess: reloadTable,
});
});
table.find(".button-delete").click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/order/so-line/${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Line Item" %}',
onSuccess: reloadTable,
});
});
table.find(".button-add-by-sn").click(function() {
var pk = $(this).attr('pk');
inventreeGet(`/api/order/so-line/${pk}/`, {},
{
success: function(response) {
launchModalForm('{% url "so-assign-serials" %}', {
success: reloadTable,
data: {
line: pk,
part: response.part,
}
});
}
}
);
});
table.find(".button-add").click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/order/sales-order/allocation/new/`, {
success: reloadTable,
data: {
line: pk,
},
});
});
table.find(".button-build").click(function() {
var pk = $(this).attr('pk');
// Extract the row data from the table!
var idx = $(this).closest('tr').attr('data-index');
var row = table.bootstrapTable('getData')[idx];
var quantity = 1;
if (row.allocated < row.quantity) {
quantity = row.quantity - row.allocated;
loadSalesOrderLineItemTable(
'#so-lines-table',
{
order: {{ order.pk }},
status: {{ order.status }},
}
launchModalForm(`/build/new/`, {
follow: true,
data: {
part: pk,
sales_order: {{ order.id }},
quantity: quantity,
},
});
});
table.find(".button-buy").click(function() {
var pk = $(this).attr('pk');
launchModalForm("{% url 'order-parts' %}", {
data: {
parts: [pk],
},
});
});
$(".button-price").click(function() {
var pk = $(this).attr('pk');
var idx = $(this).closest('tr').attr('data-index');
var row = table.bootstrapTable('getData')[idx];
launchModalForm(
"{% url 'line-pricing' %}",
{
submit_text: '{% trans "Calculate price" %}',
data: {
line_item: pk,
quantity: row.quantity,
},
buttons: [{name: 'update_price',
title: '{% trans "Update Unit Price" %}'},],
success: reloadTable,
}
);
});
);
attachNavCallbacks({
name: 'sales-order',
default: 'order-items'
});
}
{% endblock %}

View File

@ -1,14 +0,0 @@
{% extends "modal_delete_form.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block pre_form_content %}
<div class='alert alert-block alert-warning'>
{% trans "This action will unallocate the following stock from the Sales Order" %}:
<br>
<strong>
{% decimal allocation.get_allocated %} x {{ allocation.line.part.full_name }}
{% if allocation.item.location %} ({{ allocation.get_location }}){% endif %}
</strong>
</div>
{% endblock %}

View File

@ -36,6 +36,7 @@ purchase_order_urls = [
sales_order_detail_urls = [
url(r'^cancel/', views.SalesOrderCancel.as_view(), name='so-cancel'),
url(r'^ship/', views.SalesOrderShip.as_view(), name='so-ship'),
url(r'^export/', views.SalesOrderExport.as_view(), name='so-export'),
url(r'^.*$', views.SalesOrderDetail.as_view(), name='so-detail'),
]
@ -43,12 +44,7 @@ sales_order_detail_urls = [
sales_order_urls = [
# URLs for sales order allocations
url(r'^allocation/', include([
url(r'^new/', views.SalesOrderAllocationCreate.as_view(), name='so-allocation-create'),
url(r'^assign-serials/', views.SalesOrderAssignSerials.as_view(), name='so-assign-serials'),
url(r'(?P<pk>\d+)/', include([
url(r'^edit/', views.SalesOrderAllocationEdit.as_view(), name='so-allocation-edit'),
url(r'^delete/', views.SalesOrderAllocationDelete.as_view(), name='so-allocation-delete'),
])),
])),
# Display detail view for a single SalesOrder

View File

@ -23,13 +23,12 @@ from decimal import Decimal, InvalidOperation
from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import SalesOrder, SalesOrderLineItem
from .models import SalesOrderAllocation
from .admin import POLineItemResource
from .admin import POLineItemResource, SOLineItemResource
from build.models import Build
from company.models import Company, SupplierPart # ManufacturerPart
from stock.models import StockItem
from part.models import Part
from common.models import InvenTreeSetting
from common.forms import UploadFileForm, MatchFieldForm
from common.views import FileManagementFormView
from common.files import FileManager
@ -37,7 +36,7 @@ from common.files import FileManager
from . import forms as order_forms
from part.views import PartPricing
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.views import AjaxView, AjaxUpdateView
from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.helpers import extract_serial_numbers
from InvenTree.views import InvenTreeRoleMixin
@ -437,6 +436,33 @@ class PurchaseOrderUpload(FileManagementFormView):
return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']}))
class SalesOrderExport(AjaxView):
"""
Export a sales order
- File format can optionally be passed as a query parameter e.g. ?format=CSV
- Default file format is CSV
"""
model = SalesOrder
role_required = 'sales_order.view'
def get(self, request, *args, **kwargs):
order = get_object_or_404(SalesOrder, pk=self.kwargs.get('pk', None))
export_format = request.GET.get('format', 'csv')
filename = f"{str(order)} - {order.customer.name}.{export_format}"
dataset = SOLineItemResource().export(queryset=order.lines.all())
filedata = dataset.export(format=export_format)
return DownloadFile(filedata, filename)
class PurchaseOrderExport(AjaxView):
""" File download for a purchase order
@ -451,7 +477,7 @@ class PurchaseOrderExport(AjaxView):
def get(self, request, *args, **kwargs):
order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
order = get_object_or_404(PurchaseOrder, pk=self.kwargs.get('pk', None))
export_format = request.GET.get('format', 'csv')
@ -976,105 +1002,6 @@ class SalesOrderAssignSerials(AjaxView, FormMixin):
)
class SalesOrderAllocationCreate(AjaxCreateView):
""" View for creating a new SalesOrderAllocation """
model = SalesOrderAllocation
form_class = order_forms.CreateSalesOrderAllocationForm
ajax_form_title = _('Allocate Stock to Order')
def get_initial(self):
initials = super().get_initial().copy()
line_id = self.request.GET.get('line', None)
if line_id is not None:
line = SalesOrderLineItem.objects.get(pk=line_id)
initials['line'] = line
# Search for matching stock items, pre-fill if there is only one
items = StockItem.objects.filter(part=line.part)
quantity = line.quantity - line.allocated_quantity()
if quantity < 0:
quantity = 0
if items.count() == 1:
item = items.first()
initials['item'] = item
# Reduce the quantity IF there is not enough stock
qmax = item.quantity - item.allocation_count()
if qmax < quantity:
quantity = qmax
initials['quantity'] = quantity
return initials
def get_form(self):
form = super().get_form()
line_id = form['line'].value()
# If a line item has been specified, reduce the queryset for the stockitem accordingly
try:
line = SalesOrderLineItem.objects.get(pk=line_id)
# Construct a queryset for allowable stock items
queryset = StockItem.objects.filter(StockItem.IN_STOCK_FILTER)
# Ensure the part reference matches
queryset = queryset.filter(part=line.part)
# Exclude StockItem which are already allocated to this order
allocated = [allocation.item.pk for allocation in line.allocations.all()]
queryset = queryset.exclude(pk__in=allocated)
# Exclude stock items which have expired
if not InvenTreeSetting.get_setting('STOCK_ALLOW_EXPIRED_SALE'):
queryset = queryset.exclude(StockItem.EXPIRED_FILTER)
form.fields['item'].queryset = queryset
# Hide the 'line' field
form.fields['line'].widget = HiddenInput()
except (ValueError, SalesOrderLineItem.DoesNotExist):
pass
return form
class SalesOrderAllocationEdit(AjaxUpdateView):
model = SalesOrderAllocation
form_class = order_forms.EditSalesOrderAllocationForm
ajax_form_title = _('Edit Allocation Quantity')
def get_form(self):
form = super().get_form()
# Prevent the user from editing particular fields
form.fields.pop('item')
form.fields.pop('line')
return form
class SalesOrderAllocationDelete(AjaxDeleteView):
model = SalesOrderAllocation
ajax_form_title = _("Remove allocation")
context_object_name = 'allocation'
ajax_template_name = "order/so_allocation_delete.html"
class LineItemPricing(PartPricing):
""" View for inspecting part pricing information """

View File

@ -189,12 +189,15 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
# Process manufacturer part
for manufacturer_idx, manufacturer_part in enumerate(manufacturer_parts):
if manufacturer_part:
if manufacturer_part and manufacturer_part.manufacturer:
manufacturer_name = manufacturer_part.manufacturer.name
else:
manufacturer_name = ''
manufacturer_mpn = manufacturer_part.MPN
if manufacturer_part:
manufacturer_mpn = manufacturer_part.MPN
else:
manufacturer_mpn = ''
# Generate column names for this manufacturer
k_man = manufacturer_headers[0] + "_" + str(manufacturer_idx)
@ -210,12 +213,15 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
# Process supplier parts
for supplier_idx, supplier_part in enumerate(manufacturer_part.supplier_parts.all()):
if supplier_part.supplier:
if supplier_part.supplier and supplier_part.supplier:
supplier_name = supplier_part.supplier.name
else:
supplier_name = ''
supplier_sku = supplier_part.SKU
if supplier_part:
supplier_sku = supplier_part.SKU
else:
supplier_sku = ''
# Generate column names for this supplier
k_sup = str(supplier_headers[0]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx)

View File

@ -328,6 +328,12 @@
// If image / thumbnail data present, live update
if (data.image) {
$('#part-image').attr('src', data.image);
// Reset the "modal image" view
$('#part-thumb').click(function() {
showModalImage(data.image);
});
} else {
// Otherwise, reload the page
location.reload();

View File

@ -34,6 +34,7 @@ from company.models import Company, SupplierPart
from company.serializers import CompanySerializer, SupplierPartSerializer
from order.models import PurchaseOrder
from order.models import SalesOrder, SalesOrderAllocation
from order.serializers import POSerializer
import common.settings
@ -645,6 +646,31 @@ class StockList(generics.ListCreateAPIView):
# Filter StockItem without build allocations or sales order allocations
queryset = queryset.filter(Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True))
# Exclude StockItems which are already allocated to a particular SalesOrder
exclude_so_allocation = params.get('exclude_so_allocation', None)
if exclude_so_allocation is not None:
try:
order = SalesOrder.objects.get(pk=exclude_so_allocation)
# Grab all the active SalesOrderAllocations for this order
allocations = SalesOrderAllocation.objects.filter(
line__pk__in=[
line.pk for line in order.lines.all()
]
)
# Exclude any stock item which is already allocated to the sales order
queryset = queryset.exclude(
pk__in=[
a.item.pk for a in allocations
]
)
except (ValueError, SalesOrder.DoesNotExist):
pass
# Does the client wish to filter by the Part ID?
part_id = params.get('part', None)

View File

@ -42,6 +42,12 @@
</a>
</li>
<li class='list-group-item' title='{% trans "Forms" %}'>
<a href='#' class='nav-toggle' id='select-user-forms'>
<span class='fas fa-table'></span>{% trans "Forms" %}
</a>
</li>
<!--
<li class='list-group-item' title='{% trans "Settings" %}'>
<a href='#' class='nav-toggle' id='select-user-settings'>

View File

@ -17,7 +17,6 @@
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_FORMS" icon="fa-dollar-sign" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_RELATED" icon="fa-random" %}
{% include "InvenTree/settings/setting.html" with key="PART_CREATE_INITIAL" icon="fa-boxes" %}

View File

@ -20,6 +20,7 @@
{% include "InvenTree/settings/user_search.html" %}
{% include "InvenTree/settings/user_labels.html" %}
{% include "InvenTree/settings/user_reports.html" %}
{% include "InvenTree/settings/user_forms.html" %}
{% if user.is_staff %}

View File

@ -0,0 +1,22 @@
{% extends "panel.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block label %}user-forms{% endblock %}
{% block heading %}
{% trans "Form Settings" %}
{% endblock %}
{% block content %}
<div class='row'>
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" user_setting=True %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -10,6 +10,7 @@
/* exported
attachClipboard,
enableDragAndDrop,
exportFormatOptions,
inventreeDocReady,
inventreeLoad,
inventreeSave,
@ -46,6 +47,31 @@ function attachClipboard(selector, containerselector, textElement) {
}
/**
* Return a standard list of export format options *
*/
function exportFormatOptions() {
return [
{
value: 'csv',
display_name: 'CSV',
},
{
value: 'tsv',
display_name: 'TSV',
},
{
value: 'xls',
display_name: 'XLS',
},
{
value: 'xlsx',
display_name: 'XLSX',
},
];
}
function inventreeDocReady() {
/* Run this function when the HTML document is loaded.
* This will be called for every page that extends "base.html"

View File

@ -42,6 +42,8 @@ function buildFormFields() {
part_detail: true,
}
},
sales_order: {
},
batch: {},
target_date: {},
take_from: {},
@ -76,23 +78,32 @@ function newBuildOrder(options={}) {
var fields = buildFormFields();
// Specify the target part
if (options.part) {
fields.part.value = options.part;
}
// Specify the desired quantity
if (options.quantity) {
fields.quantity.value = options.quantity;
}
// Specify the parent build order
if (options.parent) {
fields.parent.value = options.parent;
}
// Specify a parent sales order
if (options.sales_order) {
fields.sales_order.value = options.sales_order;
}
constructForm(`/api/build/`, {
fields: fields,
follow: true,
method: 'POST',
title: '{% trans "Create Build Order" %}'
title: '{% trans "Create Build Order" %}',
onSuccess: options.onSuccess,
});
}

View File

@ -1568,6 +1568,9 @@ function renderModelData(name, model, data, parameters, options) {
case 'partparametertemplate':
renderer = renderPartParameterTemplate;
break;
case 'salesorder':
renderer = renderSalesOrder;
break;
case 'manufacturerpart':
renderer = renderManufacturerPart;
break;

View File

@ -159,7 +159,24 @@ function renderPart(name, data, parameters, options) {
html += ` - <i>${data.description}</i>`;
}
html += `<span class='float-right'><small>{% trans "Part ID" %}: ${data.pk}</small></span>`;
var stock = '';
// Display available part quantity
if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) {
if (data.in_stock == 0) {
stock = `<span class='label-form label-red'>{% trans "No Stock" %}</span>`;
} else {
stock = `<span class='label-form label-green'>{% trans "In Stock" %}: ${data.in_stock}</span>`;
}
}
html += `
<span class='float-right'>
<small>
${stock}
{% trans "Part ID" %}: ${data.pk}
</small>
</span>`;
return html;
}
@ -199,6 +216,26 @@ function renderOwner(name, data, parameters, options) {
}
// Renderer for "SalesOrder" model
// eslint-disable-next-line no-unused-vars
function renderSalesOrder(name, data, parameters, options) {
var html = `<span>${data.reference}</span>`;
if (data.description) {
html += ` - <i>${data.description}</i>`;
}
html += `
<span class='float-right'>
<small>
{% trans "Order ID" %}: ${data.pk}
</small>
</span>`;
return html;
}
// Renderer for "PartCategory" model
// eslint-disable-next-line no-unused-vars
function renderPartCategory(name, data, parameters, options) {

View File

@ -21,9 +21,11 @@
/* exported
createSalesOrder,
editPurchaseOrderLineItem,
exportOrder,
loadPurchaseOrderLineItemTable,
loadPurchaseOrderTable,
loadSalesOrderAllocationTable,
loadSalesOrderLineItemTable,
loadSalesOrderTable,
newPurchaseOrderFromOrderWizard,
newSupplierPartFromOrderWizard,
@ -186,6 +188,49 @@ function newSupplierPartFromOrderWizard(e) {
});
}
/**
* Export an order (PurchaseOrder or SalesOrder)
*
* - Display a simple form which presents the user with export options
*
*/
function exportOrder(redirect_url, options={}) {
var format = options.format;
// If default format is not provided, lookup
if (!format) {
format = inventreeLoad('order-export-format', 'csv');
}
constructFormBody({}, {
title: '{% trans "Export Order" %}',
fields: {
format: {
label: '{% trans "Format" %}',
help_text: '{% trans "Select file format" %}',
required: true,
type: 'choice',
value: format,
choices: exportFormatOptions(),
}
},
onSubmit: function(fields, opts) {
var format = getFormFieldValue('format', fields['format'], opts);
// Save the format for next time
inventreeSave('order-export-format', format);
// Hide the modal
$(opts.modal).modal('hide');
// Download the file!
location.href = `${redirect_url}?format=${format}`;
}
});
}
function newPurchaseOrderFromOrderWizard(e) {
/* Create a new purchase order directly from an order form.
* Launches a secondary modal and (if successful),
@ -531,6 +576,7 @@ function editPurchaseOrderLineItem(e) {
var url = $(src).attr('url');
// TODO: Migrate this to the API forms
launchModalForm(url, {
reload: true,
});
@ -546,7 +592,8 @@ function removePurchaseOrderLineItem(e) {
var src = e.target || e.srcElement;
var url = $(src).attr('url');
// TODO: Migrate this to the API forms
launchModalForm(url, {
reload: true,
});
@ -1126,3 +1173,601 @@ function loadSalesOrderAllocationTable(table, options={}) {
]
});
}
/**
* Display an "allocations" sub table, showing stock items allocated againt a sales order
* @param {*} index
* @param {*} row
* @param {*} element
*/
function showAllocationSubTable(index, row, element, options) {
// Construct a sub-table element
var html = `
<div class='sub-table'>
<table class='table table-striped table-condensed' id='allocation-table-${row.pk}'>
</table>
</div>`;
element.html(html);
var table = $(`#allocation-table-${row.pk}`);
// Is the parent SalesOrder pending?
var pending = options.status == {{ SalesOrderStatus.PENDING }};
function setupCallbacks() {
// Add callbacks for 'edit' buttons
table.find('.button-allocation-edit').click(function() {
var pk = $(this).attr('pk');
// Edit the sales order alloction
constructForm(
`/api/order/so-allocation/${pk}/`,
{
fields: {
quantity: {},
},
title: '{% trans "Edit Stock Allocation" %}',
onSuccess: function() {
// Refresh the parent table
$(options.table).bootstrapTable('refresh');
},
},
);
});
// Add callbacks for 'delete' buttons
table.find('.button-allocation-delete').click(function() {
var pk = $(this).attr('pk');
constructForm(
`/api/order/so-allocation/${pk}/`,
{
method: 'DELETE',
confirmMessage: '{% trans "Confirm Delete Operation" %}',
title: '{% trans "Delete Stock Allocation" %}',
onSuccess: function() {
// Refresh the parent table
$(options.table).bootstrapTable('refresh');
}
}
);
});
}
table.bootstrapTable({
onPostBody: setupCallbacks,
data: row.allocations,
showHeader: false,
columns: [
{
field: 'allocated',
title: '{% trans "Quantity" %}',
formatter: function(value, row, index, field) {
var text = '';
if (row.serial != null && row.quantity == 1) {
text = `{% trans "Serial Number" %}: ${row.serial}`;
} else {
text = `{% trans "Quantity" %}: ${row.quantity}`;
}
return renderLink(text, `/stock/item/${row.item}/`);
},
},
{
field: 'location',
title: '{% trans "Location" %}',
formatter: function(value, row, index, field) {
// Location specified
if (row.location) {
return renderLink(
row.location_detail.pathstring || '{% trans "Location" %}',
`/stock/location/${row.location}/`
);
} else {
return `<i>{% trans "Stock location not specified" %}`;
}
},
},
// TODO: ?? What is 'po' field all about?
/*
{
field: 'po'
},
*/
{
field: 'buttons',
title: '{% trans "Actions" %}',
formatter: function(value, row, index, field) {
var html = `<div class='btn-group float-right' role='group'>`;
var pk = row.pk;
if (pending) {
html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
}
html += '</div>';
return html;
},
},
],
});
}
/**
* Display a "fulfilled" sub table, showing stock items fulfilled against a purchase order
*/
function showFulfilledSubTable(index, row, element, options) {
// Construct a table showing stock items which have been fulfilled against this line item
if (!options.order) {
return 'ERROR: Order ID not supplied';
}
var id = `fulfilled-table-${row.pk}`;
var html = `
<div class='sub-table'>
<table class='table table-striped table-condensed' id='${id}'>
</table>
</div>`;
element.html(html);
$(`#${id}`).bootstrapTable({
url: '{% url "api-stock-list" %}',
queryParams: {
part: row.part,
sales_order: options.order,
},
showHeader: false,
columns: [
{
field: 'pk',
visible: false,
},
{
field: 'stock',
formatter: function(value, row) {
var text = '';
if (row.serial && row.quantity == 1) {
text = `{% trans "Serial Number" %}: ${row.serial}`;
} else {
text = `{% trans "Quantity" %}: ${row.quantity}`;
}
return renderLink(text, `/stock/item/${row.pk}/`);
},
},
/*
{
field: 'po'
},
*/
],
});
}
/**
* Load a table displaying line items for a particular SalesOrder
*
* @param {String} table : HTML ID tag e.g. '#table'
* @param {Object} options : object which contains:
* - order {integer} : pk of the SalesOrder
* - status: {integer} : status code for the order
*/
function loadSalesOrderLineItemTable(table, options={}) {
options.table = table;
options.params = options.params || {};
if (!options.order) {
console.log('ERROR: function called without order ID');
return;
}
if (!options.status) {
console.log('ERROR: function called without order status');
return;
}
options.params.order = options.order;
options.params.part_detail = true;
options.params.allocations = true;
var filters = loadTableFilters('salesorderlineitem');
for (var key in options.params) {
filters[key] = options.params[key];
}
options.url = options.url || '{% url "api-so-line-list" %}';
var filter_target = options.filter_target || '#filter-list-sales-order-lines';
setupFilterList('salesorderlineitems', $(table), filter_target);
// Is the order pending?
var pending = options.status == {{ SalesOrderStatus.PENDING }};
// Has the order shipped?
var shipped = options.status == {{ SalesOrderStatus.SHIPPED }};
// Show detail view if the PurchaseOrder is PENDING or SHIPPED
var show_detail = pending || shipped;
// Table columns to display
var columns = [
/*
{
checkbox: true,
visible: true,
switchable: false,
},
*/
{
sortable: true,
sortName: 'part__name',
field: 'part',
title: '{% trans "Part" %}',
switchable: false,
formatter: function(value, row, index, field) {
if (row.part) {
return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${value}/`);
} else {
return '-';
}
},
footerFormatter: function() {
return '{% trans "Total" %}';
},
},
{
sortable: true,
field: 'reference',
title: '{% trans "Reference" %}',
switchable: false,
},
{
sortable: true,
field: 'quantity',
title: '{% trans "Quantity" %}',
footerFormatter: function(data) {
return data.map(function(row) {
return +row['quantity'];
}).reduce(function(sum, i) {
return sum + i;
}, 0);
},
switchable: false,
},
{
sortable: true,
field: 'sale_price',
title: '{% trans "Unit Price" %}',
formatter: function(value, row) {
return row.sale_price_string || row.sale_price;
}
},
{
sortable: true,
title: '{% trans "Total price" %}',
formatter: function(value, row) {
var total = row.sale_price * row.quantity;
var formatter = new Intl.NumberFormat(
'en-US',
{
style: 'currency',
currency: row.sale_price_currency
}
);
return formatter.format(total);
},
footerFormatter: function(data) {
var total = data.map(function(row) {
return +row['sale_price'] * row['quantity'];
}).reduce(function(sum, i) {
return sum + i;
}, 0);
var currency = (data.slice(-1)[0] && data.slice(-1)[0].sale_price_currency) || 'USD';
var formatter = new Intl.NumberFormat(
'en-US',
{
style: 'currency',
currency: currency
}
);
return formatter.format(total);
}
},
];
if (pending) {
columns.push(
{
field: 'stock',
title: '{% trans "In Stock" %}',
formatter: function(value, row) {
return row.part_detail.stock;
},
},
);
}
columns.push(
{
field: 'allocated',
title: pending ? '{% trans "Allocated" %}' : '{% trans "Fulfilled" %}',
switchable: false,
formatter: function(value, row, index, field) {
var quantity = pending ? row.allocated : row.fulfilled;
return makeProgressBar(quantity, row.quantity, {
id: `order-line-progress-${row.pk}`,
});
},
sorter: function(valA, valB, rowA, rowB) {
var A = pending ? rowA.allocated : rowA.fulfilled;
var B = pending ? rowB.allocated : rowB.fulfilled;
if (A == 0 && B == 0) {
return (rowA.quantity > rowB.quantity) ? 1 : -1;
}
var progressA = parseFloat(A) / rowA.quantity;
var progressB = parseFloat(B) / rowB.quantity;
return (progressA < progressB) ? 1 : -1;
}
},
{
field: 'notes',
title: '{% trans "Notes" %}',
},
);
if (pending) {
columns.push({
field: 'buttons',
formatter: function(value, row, index, field) {
var html = `<div class='btn-group float-right' role='group'>`;
var pk = row.pk;
if (row.part) {
var part = row.part_detail;
if (part.trackable) {
html += makeIconButton('fa-hashtag icon-green', 'button-add-by-sn', pk, '{% trans "Allocate serial numbers" %}');
}
html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', pk, '{% trans "Allocate stock" %}');
if (part.purchaseable) {
html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}');
}
if (part.assembly) {
html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build stock" %}');
}
html += makeIconButton('fa-dollar-sign icon-green', 'button-price', pk, '{% trans "Calculate price" %}');
}
html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line item " %}');
html += `</div>`;
return html;
}
});
}
function reloadTable() {
$(table).bootstrapTable('refresh');
}
// Configure callback functions once the table is loaded
function setupCallbacks() {
// Callback for editing line items
$(table).find('.button-edit').click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/order/so-line/${pk}/`, {
fields: {
quantity: {},
reference: {},
sale_price: {},
sale_price_currency: {},
notes: {},
},
title: '{% trans "Edit Line Item" %}',
onSuccess: reloadTable,
});
});
// Callback for deleting line items
$(table).find('.button-delete').click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/order/so-line/${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Line Item" %}',
onSuccess: reloadTable,
});
});
// Callback for allocating stock items by serial number
$(table).find('.button-add-by-sn').click(function() {
var pk = $(this).attr('pk');
// TODO: Migrate this form to the API forms
inventreeGet(`/api/order/so-line/${pk}/`, {},
{
success: function(response) {
launchModalForm('{% url "so-assign-serials" %}', {
success: reloadTable,
data: {
line: pk,
part: response.part,
}
});
}
}
);
});
// Callback for allocation stock items to the order
$(table).find('.button-add').click(function() {
var pk = $(this).attr('pk');
var line_item = $(table).bootstrapTable('getRowByUniqueId', pk);
var fields = {
// SalesOrderLineItem reference
line: {
hidden: true,
value: pk,
},
item: {
filters: {
part_detail: true,
location_detail: true,
in_stock: true,
part: line_item.part,
exclude_so_allocation: options.order,
}
},
quantity: {
},
};
// Exclude expired stock?
if (global_settings.STOCK_ENABLE_EXPIRY && !global_settings.STOCK_ALLOW_EXPIRED_SALE) {
fields.item.filters.expired = false;
}
constructForm(
`/api/order/so-allocation/`,
{
method: 'POST',
fields: fields,
title: '{% trans "Allocate Stock Item" %}',
onSuccess: reloadTable,
}
);
});
// Callback for creating a new build
$(table).find('.button-build').click(function() {
var pk = $(this).attr('pk');
// Extract the row data from the table!
var idx = $(this).closest('tr').attr('data-index');
var row = $(table).bootstrapTable('getData')[idx];
var quantity = 1;
if (row.allocated < row.quantity) {
quantity = row.quantity - row.allocated;
}
// Create a new build order
newBuildOrder({
part: pk,
sales_order: options.order,
quantity: quantity,
success: reloadTable
});
});
// Callback for purchasing parts
$(table).find('.button-buy').click(function() {
var pk = $(this).attr('pk');
launchModalForm('{% url "order-parts" %}', {
data: {
parts: [
pk
],
},
});
});
// Callback for displaying price
$(table).find('.button-price').click(function() {
var pk = $(this).attr('pk');
var idx = $(this).closest('tr').attr('data-index');
var row = $(table).bootstrapTable('getData')[idx];
launchModalForm(
'{% url "line-pricing" %}',
{
submit_text: '{% trans "Calculate price" %}',
data: {
line_item: pk,
quantity: row.quantity,
},
buttons: [
{
name: 'update_price',
title: '{% trans "Update Unit Price" %}'
},
],
success: reloadTable,
}
);
});
}
$(table).inventreeTable({
onPostBody: setupCallbacks,
name: 'salesorderlineitems',
sidePagination: 'server',
formatNoMatches: function() {
return '{% trans "No matching line items" %}';
},
queryParams: filters,
original: options.params,
url: options.url,
showFooter: true,
uniqueId: 'pk',
detailView: show_detail,
detailViewByClick: show_detail,
detailFilter: function(index, row) {
if (pending) {
// Order is pending
return row.allocated > 0;
} else {
return row.fulfilled > 0;
}
},
detailFormatter: function(index, row, element) {
if (pending) {
return showAllocationSubTable(index, row, element, options);
} else {
return showFulfilledSubTable(index, row, element, options);
}
},
columns: columns,
});
}

View File

@ -98,24 +98,7 @@ function exportStock(params={}) {
required: true,
type: 'choice',
value: 'csv',
choices: [
{
value: 'csv',
display_name: 'CSV',
},
{
value: 'tsv',
display_name: 'TSV',
},
{
value: 'xls',
display_name: 'XLS',
},
{
value: 'xlsx',
display_name: 'XLSX',
},
],
choices: exportFormatOptions(),
},
sublocations: {
label: '{% trans "Include Sublocations" %}',

40
ci/check_api_endpoint.py Normal file
View File

@ -0,0 +1,40 @@
"""
Test that the root API endpoint is available.
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import json
import requests
# We expect the server to be running on the local host
url = "http://localhost:8000/api/"
print("Testing InvenTree API endpoint")
response = requests.get(url)
assert(response.status_code == 200)
print("- Response 200 OK")
data = json.loads(response.text)
required_keys = [
'server',
'version',
'apiVersion',
'worker_running',
]
for key in required_keys:
assert(key in data)
print(f"- Found key '{key}'")
# Check that the worker is running
assert(data['worker_running'])
print("- Background worker is operational")
print("API Endpoint Tests Passed OK")