2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 20:45:44 +00:00

Merge branch 'inventree:master' into internal-price

This commit is contained in:
Matthias Mair
2021-06-17 16:42:57 +02:00
committed by GitHub
44 changed files with 2596 additions and 2319 deletions

View File

@ -26,10 +26,9 @@ def canAppAccessDatabase():
'flush',
'loaddata',
'dumpdata',
'makemirations',
'makemigrations',
'migrate',
'check',
'mediarestore',
'shell',
'createsuperuser',
'wait_for_db',

View File

@ -97,7 +97,7 @@ DOCKER = _is_true(get_setting(
# Configure logging settings
log_level = get_setting(
'INVENTREE_LOG_LEVEL',
CONFIG.get('log_level', 'DEBUG')
CONFIG.get('log_level', 'WARNING')
)
logging.basicConfig(
@ -191,7 +191,7 @@ STATIC_URL = '/static/'
STATIC_ROOT = os.path.abspath(
get_setting(
'INVENTREE_STATIC_ROOT',
CONFIG.get('static_root', '/home/inventree/static')
CONFIG.get('static_root', '/home/inventree/data/static')
)
)

View File

@ -37,6 +37,7 @@ from django.conf.urls.static import static
from django.views.generic.base import RedirectView
from rest_framework.documentation import include_docs_urls
from .views import auth_request
from .views import IndexView, SearchView, DatabaseStatsView
from .views import SettingsView, EditUserView, SetPasswordView
from .views import CurrencySettingsView, CurrencyRefreshView
@ -155,24 +156,28 @@ urlpatterns = [
url(r'^search/', SearchView.as_view(), name='search'),
url(r'^stats/', DatabaseStatsView.as_view(), name='stats'),
url(r'^auth/?', auth_request),
url(r'^api/', include(apipatterns)),
url(r'^api-doc/', include_docs_urls(title='InvenTree API')),
url(r'^markdownx/', include('markdownx.urls')),
]
# Static file access
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
# Server running in "DEBUG" mode?
if settings.DEBUG:
# Static file access
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
# Media file access
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Media file access
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Debug toolbar access (if in DEBUG mode)
if settings.DEBUG and 'debug_toolbar' in settings.INSTALLED_APPS:
import debug_toolbar
urlpatterns = [
path('__debug/', include(debug_toolbar.urls)),
] + urlpatterns
# Debug toolbar access (only allowed in DEBUG mode)
if 'debug_toolbar' in settings.INSTALLED_APPS:
import debug_toolbar
urlpatterns = [
path('__debug/', include(debug_toolbar.urls)),
] + urlpatterns
# Send any unknown URLs to the parts page
urlpatterns += [url(r'^.*$', RedirectView.as_view(url='/index/', permanent=False), name='index')]

View File

@ -8,7 +8,7 @@ import re
import common.models
INVENTREE_SW_VERSION = "0.2.3 pre"
INVENTREE_SW_VERSION = "0.2.4 pre"
"""
Increment thi API version number whenever there is a significant change to the API that any clients need to know about

View File

@ -10,7 +10,7 @@ from __future__ import unicode_literals
from django.utils.translation import gettext_lazy as _
from django.template.loader import render_to_string
from django.http import JsonResponse, HttpResponseRedirect
from django.http import HttpResponse, JsonResponse, HttpResponseRedirect
from django.urls import reverse_lazy
from django.conf import settings
@ -36,6 +36,19 @@ from .helpers import str2bool
from rest_framework import views
def auth_request(request):
"""
Simple 'auth' endpoint used to determine if the user is authenticated.
Useful for (for example) redirecting authentication requests through
django's permission framework.
"""
if request.user.is_authenticated:
return HttpResponse(status=200)
else:
return HttpResponse(status=403)
class TreeSerializer(views.APIView):
""" JSON View for serializing a Tree object.

View File

@ -1289,10 +1289,23 @@ class BuildItem(models.Model):
Return qualified URL for part thumbnail image
"""
thumb_url = None
if self.stock_item and self.stock_item.part:
return InvenTree.helpers.getMediaUrl(self.stock_item.part.image.thumbnail.url)
elif self.bom_item and self.stock_item.sub_part:
return InvenTree.helpers.getMediaUrl(self.bom_item.sub_part.image.thumbnail.url)
try:
# Try to extract the thumbnail
thumb_url = self.stock_item.part.image.thumbnail.url
except:
pass
if thumb_url is None and self.bom_item and self.bom_item.sub_part:
try:
thumb_url = self.bom_item.sub_part.image.thumbnail.url
except:
pass
if thumb_url is not None:
return InvenTree.helpers.getMediaUrl(thumb_url)
else:
return InvenTree.helpers.getBlankThumbnail()

View File

@ -52,3 +52,10 @@
part: 2
supplier: 2
SKU: 'ZERGM312'
- model: company.supplierpart
pk: 5
fields:
part: 4
supplier: 2
SKU: 'R_4K7_0603'

View File

@ -65,7 +65,7 @@ class CompanySimpleTest(TestCase):
self.assertEqual(acme.supplied_part_count, 4)
self.assertTrue(appel.has_parts)
self.assertEqual(appel.supplied_part_count, 3)
self.assertEqual(appel.supplied_part_count, 4)
self.assertTrue(zerg.has_parts)
self.assertEqual(zerg.supplied_part_count, 2)

View File

@ -129,9 +129,9 @@ cors:
media_root: '/home/inventree/data/media'
# STATIC_ROOT is the local filesystem location for storing static files
# By default, it is stored under /home/inventree
# By default, it is stored under /home/inventree/data/static
# Use environment variable INVENTREE_STATIC_ROOT
static_root: '/home/inventree/static'
static_root: '/home/inventree/data/static'
# Optional URL schemes to allow in URL fields
# By default, only the following schemes are allowed: ['http', 'https', 'ftp', 'ftps']

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -68,6 +68,7 @@
order: 1
part: 1
quantity: 100
destination: 5 # Desk/Drawer_1
# 250 x ACME0002 (M2x4 LPHS)
# Partially received (50)
@ -95,3 +96,10 @@
part: 3
quantity: 100
# 1 x R_4K7_0603
- model: order.purchaseorderlineitem
pk: 23
fields:
order: 1
part: 5
quantity: 1

View File

@ -79,12 +79,17 @@ class ShipSalesOrderForm(HelperForm):
class ReceivePurchaseOrderForm(HelperForm):
location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), required=True, label=_('Location'), help_text=_('Receive parts to this location'))
location = TreeNodeChoiceField(
queryset=StockLocation.objects.all(),
required=True,
label=_("Destination"),
help_text=_("Receive parts to this location"),
)
class Meta:
model = PurchaseOrder
fields = [
'location',
"location",
]
@ -195,6 +200,7 @@ class EditPurchaseOrderLineItemForm(HelperForm):
'quantity',
'reference',
'purchase_price',
'destination',
'notes',
]

View File

@ -0,0 +1,29 @@
# Generated by Django 3.2 on 2021-05-13 22:38
from django.db import migrations
import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
("stock", "0063_auto_20210511_2343"),
("order", "0045_auto_20210504_1946"),
]
operations = [
migrations.AddField(
model_name="purchaseorderlineitem",
name="destination",
field=mptt.fields.TreeForeignKey(
blank=True,
help_text="Where does the Purchaser want this item to be stored?",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="po_lines",
to="stock.stocklocation",
verbose_name="Destination",
),
),
]

View File

@ -20,6 +20,7 @@ from django.utils.translation import ugettext_lazy as _
from common.settings import currency_code_default
from markdownx.models import MarkdownxField
from mptt.models import TreeForeignKey
from djmoney.models.fields import MoneyField
@ -672,6 +673,29 @@ class PurchaseOrderLineItem(OrderLineItem):
help_text=_('Unit purchase price'),
)
destination = TreeForeignKey(
'stock.StockLocation', on_delete=models.DO_NOTHING,
verbose_name=_('Destination'),
related_name='po_lines',
blank=True, null=True,
help_text=_('Where does the Purchaser want this item to be stored?')
)
def get_destination(self):
"""Show where the line item is or should be placed"""
# NOTE: If a line item gets split when recieved, only an arbitrary
# stock items location will be reported as the location for the
# entire line.
for stock in stock_models.StockItem.objects.filter(
supplier_part=self.part, purchase_order=self.order
):
if stock.location:
return stock.location
if self.destination:
return self.destination
if self.part and self.part.part and self.part.part.default_location:
return self.part.part.default_location
def remaining(self):
""" Calculate the number of items remaining to be received """
r = self.quantity - self.received

View File

@ -17,6 +17,7 @@ from InvenTree.serializers import InvenTreeAttachmentSerializerField
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
from part.serializers import PartBriefSerializer
from stock.serializers import LocationBriefSerializer
from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import PurchaseOrderAttachment, SalesOrderAttachment
@ -116,6 +117,8 @@ class POLineItemSerializer(InvenTreeModelSerializer):
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
destination = LocationBriefSerializer(source='get_destination', read_only=True)
class Meta:
model = PurchaseOrderLineItem
@ -132,6 +135,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
'purchase_price',
'purchase_price_currency',
'purchase_price_string',
'destination',
]

View File

@ -117,6 +117,7 @@ $("#po-table").inventreeTable({
part_detail: true,
},
url: "{% url 'api-po-line-list' %}",
showFooter: true,
columns: [
{
field: 'pk',
@ -137,6 +138,9 @@ $("#po-table").inventreeTable({
return '-';
}
},
footerFormatter: function() {
return '{% trans "Total" %}'
}
},
{
field: 'part_detail.description',
@ -172,7 +176,14 @@ $("#po-table").inventreeTable({
{
sortable: true,
field: 'quantity',
title: '{% trans "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,
@ -182,6 +193,25 @@ $("#po-table").inventreeTable({
return row.purchase_price_string || row.purchase_price;
}
},
{
sortable: true,
title: '{% trans "Total price" %}',
formatter: function(value, row) {
var total = row.purchase_price * row.quantity;
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: row.purchase_price_currency});
return formatter.format(total)
},
footerFormatter: function(data) {
var total = data.map(function (row) {
return +row['purchase_price']*row['quantity']
}).reduce(function (sum, i) {
return sum + i
}, 0)
var currency = (data.slice(-1)[0] && data.slice(-1)[0].purchase_price_currency) || 'USD';
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: currency});
return formatter.format(total)
}
},
{
sortable: true,
field: 'received',
@ -204,6 +234,10 @@ $("#po-table").inventreeTable({
return (progressA < progressB) ? 1 : -1;
}
},
{
field: 'destination.pathstring',
title: '{% trans "Destination" %}',
},
{
field: 'notes',
title: '{% trans "Notes" %}',

View File

@ -22,6 +22,7 @@
<th>{% trans "Received" %}</th>
<th>{% trans "Receive" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Destination" %}</th>
<th></th>
</tr>
{% for line in lines %}
@ -53,6 +54,9 @@
</select>
</div>
</td>
<td>
{{ line.get_destination }}
</td>
<td>
<button class='btn btn-default btn-remove' onClick="removeOrderRowFromOrderWizard()" id='del_item_{{ line.id }}' title='{% trans "Remove line" %}' type='button'>
<span row='line_row_{{ line.id }}' class='fas fa-times-circle icon-red'></span>

View File

@ -199,6 +199,7 @@ $("#so-lines-table").inventreeTable({
detailFormatter: showFulfilledSubTable,
{% endif %}
{% endif %}
showFooter: true,
columns: [
{
field: 'pk',
@ -217,7 +218,10 @@ $("#so-lines-table").inventreeTable({
} else {
return '-';
}
}
},
footerFormatter: function() {
return '{% trans "Total" %}'
},
},
{
sortable: true,
@ -228,6 +232,13 @@ $("#so-lines-table").inventreeTable({
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,
@ -237,6 +248,26 @@ $("#so-lines-table").inventreeTable({
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 %}

View File

@ -87,7 +87,7 @@ class OrderTest(TestCase):
order = PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
self.assertEqual(order.lines.count(), 3)
self.assertEqual(order.lines.count(), 4)
sku = SupplierPart.objects.get(SKU='ACME-WIDGET')
part = sku.part
@ -105,11 +105,11 @@ class OrderTest(TestCase):
order.add_line_item(sku, 100)
self.assertEqual(part.on_order, 100)
self.assertEqual(order.lines.count(), 4)
self.assertEqual(order.lines.count(), 5)
# Order the same part again (it should be merged)
order.add_line_item(sku, 50)
self.assertEqual(order.lines.count(), 4)
self.assertEqual(order.lines.count(), 5)
self.assertEqual(part.on_order, 150)
# Try to order a supplier part from the wrong supplier
@ -163,7 +163,7 @@ class OrderTest(TestCase):
loc = StockLocation.objects.get(id=1)
# There should be two lines against this order
self.assertEqual(len(order.pending_line_items()), 3)
self.assertEqual(len(order.pending_line_items()), 4)
# Should fail, as order is 'PENDING' not 'PLACED"
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)

View File

@ -16,7 +16,16 @@
{% default_currency as currency %}
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% crispy form %}
<form method="post" class="form-horizontal">
{% csrf_token %}
<div class="row">
<div class="col-sm-9">{{ form|crispy }}</div>
<div class="col-sm-3">
<input type="submit" value="{% trans 'Calculate' %}" class="btn btn-primary btn-block">
</div>
</div>
</form>
<hr>
<div class="row"><div class="col col-md-6">
<h4>{% trans "Pricing ranges" %}</h4>
@ -126,8 +135,8 @@
{% if price_history %}
<hr>
<h4>{% trans 'Stock Pricing' %}<i class="fas fa-info-circle" title="Shows the prices of stock for this part
the part single price shown is the current price for that supplier part"></i></h4>
<h4>{% trans 'Stock Pricing' %}<i class="fas fa-info-circle" title="Shows the purchase prices of stock for this part.
The part single price is the current purchase price for that supplier part."></i></h4>
{% if price_history|length > 1 %}
<div style="max-width: 99%; min-height: 300px">
<canvas id="StockPriceChart"></canvas>
@ -173,7 +182,8 @@ the part single price shown is the current price for that supplier part"></i></h
{% for line in price_history %}{{ line.price_diff|stringformat:".2f" }},{% endfor %}
],
borderWidth: 1,
type: 'line'
type: 'line',
hidden: true,
},
{
label: '{% blocktrans %}Part Single Price - {{currency}}{% endblocktrans %}',
@ -184,7 +194,8 @@ the part single price shown is the current price for that supplier part"></i></h
{% for line in price_history %}{{ line.price_part|stringformat:".2f" }},{% endfor %}
],
borderWidth: 1,
type: 'line'
type: 'line',
hidden: true,
},
{% endif %}
{

View File

@ -181,6 +181,14 @@
{% endif %}
{% endif %}
{% endif %}
{% if part.trackable and part.getLatestSerialNumber %}
<tr><td colspan="3"></td></tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Latest Serial Number" %}</td>
<td>{{ part.getLatestSerialNumber }}{% include "clip.html"%}</td>
</tr>
{% endif %}
</table>
</div>
</div>

View File

@ -161,6 +161,13 @@ class StockItemSerializer(InvenTreeModelSerializer):
required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False)
purchase_price = serializers.SerializerMethodField()
def get_purchase_price(self, obj):
""" Return purchase_price (Money field) as string (includes currency) """
return str(obj.purchase_price) if obj.purchase_price else '-'
def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False)
@ -215,6 +222,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'tracking_items',
'uid',
'updated',
'purchase_price',
]
""" These fields are read-only in this context.

View File

@ -159,7 +159,7 @@ function loadStockTestResultsTable(table, options) {
// Set "parent" for each existing row
tableData.forEach(function(item, idx) {
tableData[idx].parent = options.stock_item;
tableData[idx].parent = parent_node;
});
// Once the test template data are loaded, query for test results
@ -660,6 +660,11 @@ function loadStockTable(table, options) {
title: '{% trans "Last Updated" %}',
sortable: true,
},
{
field: 'purchase_price',
title: '{% trans "Purchase Price" %}',
sortable: true,
},
{
field: 'packaging',
title: '{% trans "Packaging" %}',