2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-15 19:45:46 +00:00

Merge branch 'master' of https://github.com/inventree/InvenTree into part-import

This commit is contained in:
2021-06-18 23:10:59 +02:00
parent 413fa2e842
commit 59e6cc1a10
72 changed files with 3575 additions and 2459 deletions

3
.gitignore vendored
View File

@ -62,3 +62,6 @@ secret_key.txt
# Coverage reports # Coverage reports
.coverage .coverage
htmlcov/ htmlcov/
# Development files
dev/

View File

@ -0,0 +1,60 @@
"""
Custom management command to rebuild all MPTT models
- This is crucial after importing any fixtures, etc
"""
from django.core.management.base import BaseCommand
class Command(BaseCommand):
"""
Rebuild all database models which leverage the MPTT structure.
"""
def handle(self, *args, **kwargs):
# Part model
try:
print("Rebuilding Part objects")
from part.models import Part
Part.objects.rebuild()
except:
print("Error rebuilding Part objects")
# Part category
try:
print("Rebuilding PartCategory objects")
from part.models import PartCategory
PartCategory.objects.rebuild()
except:
print("Error rebuilding PartCategory objects")
# StockItem model
try:
print("Rebuilding StockItem objects")
from stock.models import StockItem
StockItem.objects.rebuild()
except:
print("Error rebuilding StockItem objects")
# StockLocation model
try:
print("Rebuilding StockLocation objects")
from stock.models import StockLocation
StockLocation.objects.rebuild()
except:
print("Error rebuilding StockLocation objects")
# Build model
try:
print("Rebuilding Build objects")
from build.models import Build
Build.objects.rebuild()
except:
print("Error rebuilding Build objects")

View File

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

View File

@ -98,7 +98,7 @@ DOCKER = _is_true(get_setting(
# Configure logging settings # Configure logging settings
log_level = get_setting( log_level = get_setting(
'INVENTREE_LOG_LEVEL', 'INVENTREE_LOG_LEVEL',
CONFIG.get('log_level', 'DEBUG') CONFIG.get('log_level', 'WARNING')
) )
logging.basicConfig( logging.basicConfig(
@ -192,7 +192,7 @@ STATIC_URL = '/static/'
STATIC_ROOT = os.path.abspath( STATIC_ROOT = os.path.abspath(
get_setting( get_setting(
'INVENTREE_STATIC_ROOT', '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 django.views.generic.base import RedirectView
from rest_framework.documentation import include_docs_urls from rest_framework.documentation import include_docs_urls
from .views import auth_request
from .views import IndexView, SearchView, DatabaseStatsView from .views import IndexView, SearchView, DatabaseStatsView
from .views import SettingsView, EditUserView, SetPasswordView from .views import SettingsView, EditUserView, SetPasswordView
from .views import CurrencySettingsView, CurrencyRefreshView from .views import CurrencySettingsView, CurrencyRefreshView
@ -155,20 +156,24 @@ urlpatterns = [
url(r'^search/', SearchView.as_view(), name='search'), url(r'^search/', SearchView.as_view(), name='search'),
url(r'^stats/', DatabaseStatsView.as_view(), name='stats'), url(r'^stats/', DatabaseStatsView.as_view(), name='stats'),
url(r'^auth/?', auth_request),
url(r'^api/', include(apipatterns)), url(r'^api/', include(apipatterns)),
url(r'^api-doc/', include_docs_urls(title='InvenTree API')), url(r'^api-doc/', include_docs_urls(title='InvenTree API')),
url(r'^markdownx/', include('markdownx.urls')), url(r'^markdownx/', include('markdownx.urls')),
] ]
# Server running in "DEBUG" mode?
if settings.DEBUG:
# Static file access # Static file access
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
# Media file access # Media file access
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Debug toolbar access (if in DEBUG mode) # Debug toolbar access (only allowed in DEBUG mode)
if settings.DEBUG and 'debug_toolbar' in settings.INSTALLED_APPS: if 'debug_toolbar' in settings.INSTALLED_APPS:
import debug_toolbar import debug_toolbar
urlpatterns = [ urlpatterns = [
path('__debug/', include(debug_toolbar.urls)), path('__debug/', include(debug_toolbar.urls)),

View File

@ -8,7 +8,7 @@ import re
import common.models 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 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.utils.translation import gettext_lazy as _
from django.template.loader import render_to_string 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.urls import reverse_lazy
from django.conf import settings from django.conf import settings
@ -36,6 +36,19 @@ from .helpers import str2bool
from rest_framework import views 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): class TreeSerializer(views.APIView):
""" JSON View for serializing a Tree object. """ JSON View for serializing a Tree object.

View File

@ -165,6 +165,19 @@ class BuildItemList(generics.ListCreateAPIView):
serializer_class = BuildItemSerializer serializer_class = BuildItemSerializer
def get_serializer(self, *args, **kwargs):
try:
params = self.request.query_params
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
kwargs['build_detail'] = str2bool(params.get('build_detail', False))
kwargs['location_detail'] = str2bool(params.get('location_detail', False))
except AttributeError:
pass
return self.serializer_class(*args, **kwargs)
def get_queryset(self): def get_queryset(self):
""" Override the queryset method, """ Override the queryset method,
to allow filtering by stock_item.part to allow filtering by stock_item.part

View File

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

View File

@ -13,7 +13,8 @@ from rest_framework import serializers
from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeModelSerializer
from stock.serializers import StockItemSerializerBrief from stock.serializers import StockItemSerializerBrief
from part.serializers import PartBriefSerializer from stock.serializers import LocationSerializer
from part.serializers import PartSerializer, PartBriefSerializer
from .models import Build, BuildItem from .models import Build, BuildItem
@ -99,22 +100,45 @@ class BuildItemSerializer(InvenTreeModelSerializer):
bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True) bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True)
part = serializers.IntegerField(source='stock_item.part.pk', read_only=True) part = serializers.IntegerField(source='stock_item.part.pk', read_only=True)
part_name = serializers.CharField(source='stock_item.part.full_name', read_only=True) location = serializers.IntegerField(source='stock_item.location.pk', read_only=True)
part_thumb = serializers.CharField(source='getStockItemThumbnail', read_only=True)
# Extra (optional) detail fields
part_detail = PartSerializer(source='stock_item.part', many=False, read_only=True)
build_detail = BuildSerializer(source='build', many=False, read_only=True)
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True) stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
location_detail = LocationSerializer(source='stock_item.location', read_only=True)
quantity = serializers.FloatField() quantity = serializers.FloatField()
def __init__(self, *args, **kwargs):
build_detail = kwargs.pop('build_detail', False)
part_detail = kwargs.pop('part_detail', False)
location_detail = kwargs.pop('location_detail', False)
super().__init__(*args, **kwargs)
if not build_detail:
self.fields.pop('build_detail')
if not part_detail:
self.fields.pop('part_detail')
if not location_detail:
self.fields.pop('location_detail')
class Meta: class Meta:
model = BuildItem model = BuildItem
fields = [ fields = [
'pk', 'pk',
'bom_part', 'bom_part',
'build', 'build',
'build_detail',
'install_into', 'install_into',
'location',
'location_detail',
'part', 'part',
'part_name', 'part_detail',
'part_thumb',
'stock_item', 'stock_item',
'stock_item_detail', 'stock_item_detail',
'quantity' 'quantity'

View File

@ -212,6 +212,21 @@ class InvenTreeSetting(models.Model):
'validator': bool, 'validator': bool,
}, },
'PART_INTERNAL_PRICE': {
'name': _('Internal Prices'),
'description': _('Enable internal prices for parts'),
'default': False,
'validator': bool
},
'PART_BOM_USE_INTERNAL_PRICE': {
'name': _('Internal Price as BOM-Price'),
'description': _('Use the internal price (if set) in BOM-price calculations'),
'default': False,
'validator': bool
},
'REPORT_DEBUG_MODE': { 'REPORT_DEBUG_MODE': {
'name': _('Debug Mode'), 'name': _('Debug Mode'),
'description': _('Generate reports in debug mode (HTML output)'), 'description': _('Generate reports in debug mode (HTML output)'),
@ -733,7 +748,7 @@ class PriceBreak(models.Model):
return converted.amount return converted.amount
def get_price(instance, quantity, moq=True, multiples=True, currency=None): def get_price(instance, quantity, moq=True, multiples=True, currency=None, break_name: str = 'price_breaks'):
""" Calculate the price based on quantity price breaks. """ Calculate the price based on quantity price breaks.
- Don't forget to add in flat-fee cost (base_cost field) - Don't forget to add in flat-fee cost (base_cost field)
@ -741,7 +756,10 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None):
- If order multiples are to be observed, then we need to calculate based on that, too - If order multiples are to be observed, then we need to calculate based on that, too
""" """
price_breaks = instance.price_breaks.all() if hasattr(instance, break_name):
price_breaks = getattr(instance, break_name).all()
else:
price_breaks = []
# No price break information available? # No price break information available?
if len(price_breaks) == 0: if len(price_breaks) == 0:
@ -763,7 +781,7 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None):
currency = currency_code_default() currency = currency_code_default()
pb_min = None pb_min = None
for pb in instance.price_breaks.all(): for pb in price_breaks:
# Store smallest price break # Store smallest price break
if not pb_min: if not pb_min:
pb_min = pb pb_min = pb

View File

@ -103,17 +103,11 @@ class ManufacturerPartList(generics.ListCreateAPIView):
# Do we wish to include extra detail? # Do we wish to include extra detail?
try: try:
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None)) params = self.request.query_params
except AttributeError:
pass
try: kwargs['part_detail'] = str2bool(params.get('part_detail', None))
kwargs['manufacturer_detail'] = str2bool(self.request.query_params.get('manufacturer_detail', None)) kwargs['manufacturer_detail'] = str2bool(params.get('manufacturer_detail', None))
except AttributeError: kwargs['pretty'] = str2bool(params.get('pretty', None))
pass
try:
kwargs['pretty'] = str2bool(self.request.query_params.get('pretty', None))
except AttributeError: except AttributeError:
pass pass
@ -252,22 +246,11 @@ class SupplierPartList(generics.ListCreateAPIView):
# Do we wish to include extra detail? # Do we wish to include extra detail?
try: try:
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None)) params = self.request.query_params
except AttributeError: kwargs['part_detail'] = str2bool(params.get('part_detail', None))
pass kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', None))
kwargs['manufacturer_detail'] = str2bool(self.params.get('manufacturer_detail', None))
try: kwargs['pretty'] = str2bool(params.get('pretty', None))
kwargs['supplier_detail'] = str2bool(self.request.query_params.get('supplier_detail', None))
except AttributeError:
pass
try:
kwargs['manufacturer_detail'] = str2bool(self.request.query_params.get('manufacturer_detail', None))
except AttributeError:
pass
try:
kwargs['pretty'] = str2bool(self.request.query_params.get('pretty', None))
except AttributeError: except AttributeError:
pass pass

View File

@ -52,3 +52,10 @@
part: 2 part: 2
supplier: 2 supplier: 2
SKU: 'ZERGM312' 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.assertEqual(acme.supplied_part_count, 4)
self.assertTrue(appel.has_parts) 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.assertTrue(zerg.has_parts)
self.assertEqual(zerg.supplied_part_count, 2) self.assertEqual(zerg.supplied_part_count, 2)

View File

@ -129,9 +129,9 @@ cors:
media_root: '/home/inventree/data/media' media_root: '/home/inventree/data/media'
# STATIC_ROOT is the local filesystem location for storing static files # 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 # 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 # Optional URL schemes to allow in URL fields
# By default, only the following schemes are allowed: ['http', 'https', 'ftp', 'ftps'] # 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

@ -22,9 +22,10 @@ from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import PurchaseOrderAttachment from .models import PurchaseOrderAttachment
from .serializers import POSerializer, POLineItemSerializer, POAttachmentSerializer from .serializers import POSerializer, POLineItemSerializer, POAttachmentSerializer
from .models import SalesOrder, SalesOrderLineItem from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
from .models import SalesOrderAttachment from .models import SalesOrderAttachment
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
from .serializers import SalesOrderAllocationSerializer
class POList(generics.ListCreateAPIView): class POList(generics.ListCreateAPIView):
@ -422,17 +423,11 @@ class SOLineItemList(generics.ListCreateAPIView):
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
try: try:
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False)) params = self.request.query_params
except AttributeError:
pass
try: kwargs['part_detail'] = str2bool(params.get('part_detail', False))
kwargs['order_detail'] = str2bool(self.request.query_params.get('order_detail', False)) kwargs['order_detail'] = str2bool(params.get('order_detail', False))
except AttributeError: kwargs['allocations'] = str2bool(params.get('allocations', False))
pass
try:
kwargs['allocations'] = str2bool(self.request.query_params.get('allocations', False))
except AttributeError: except AttributeError:
pass pass
@ -486,6 +481,70 @@ class SOLineItemDetail(generics.RetrieveUpdateAPIView):
serializer_class = SOLineItemSerializer serializer_class = SOLineItemSerializer
class SOAllocationList(generics.ListCreateAPIView):
"""
API endpoint for listing SalesOrderAllocation objects
"""
queryset = SalesOrderAllocation.objects.all()
serializer_class = SalesOrderAllocationSerializer
def get_serializer(self, *args, **kwargs):
try:
params = self.request.query_params
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
kwargs['item_detail'] = str2bool(params.get('item_detail', False))
kwargs['order_detail'] = str2bool(params.get('order_detail', False))
kwargs['location_detail'] = str2bool(params.get('location_detail', False))
except AttributeError:
pass
return self.serializer_class(*args, **kwargs)
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
# Filter by order
params = self.request.query_params
# Filter by "part" reference
part = params.get('part', None)
if part is not None:
queryset = queryset.filter(item__part=part)
# Filter by "order" reference
order = params.get('order', None)
if order is not None:
queryset = queryset.filter(line__order=order)
# Filter by "outstanding" order status
outstanding = params.get('outstanding', None)
if outstanding is not None:
outstanding = str2bool(outstanding)
if outstanding:
queryset = queryset.filter(line__order__status__in=SalesOrderStatus.OPEN)
else:
queryset = queryset.exclude(line__order__status__in=SalesOrderStatus.OPEN)
return queryset
filter_backends = [
DjangoFilterBackend,
]
# Default filterable fields
filter_fields = [
'item',
]
class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin): class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
""" """
API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload) API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)
@ -494,10 +553,6 @@ class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
queryset = PurchaseOrderAttachment.objects.all() queryset = PurchaseOrderAttachment.objects.all()
serializer_class = POAttachmentSerializer serializer_class = POAttachmentSerializer
filter_fields = [
'order',
]
order_api_urls = [ order_api_urls = [
# API endpoints for purchase orders # API endpoints for purchase orders
@ -512,14 +567,26 @@ order_api_urls = [
url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'), url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'),
# API endpoints for sales ordesr # API endpoints for sales ordesr
url(r'^so/(?P<pk>\d+)/$', SODetail.as_view(), name='api-so-detail'), url(r'^so/', include([
url(r'so/attachment/', include([ url(r'^(?P<pk>\d+)/$', SODetail.as_view(), name='api-so-detail'),
url(r'attachment/', include([
url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'), url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'),
])), ])),
url(r'^so/.*$', SOList.as_view(), name='api-so-list'), # List all sales orders
url(r'^.*$', SOList.as_view(), name='api-so-list'),
])),
# API endpoints for sales order line items # API endpoints for sales order line items
url(r'^so-line/(?P<pk>\d+)/$', SOLineItemDetail.as_view(), name='api-so-line-detail'), url(r'^so-line/', include([
url(r'^so-line/$', SOLineItemList.as_view(), name='api-so-line-list'), url(r'^(?P<pk>\d+)/$', SOLineItemDetail.as_view(), name='api-so-line-detail'),
url(r'^$', SOLineItemList.as_view(), name='api-so-line-list'),
])),
# API endpoints for sales order allocations
url(r'^so-allocation', include([
# List all sales order allocations
url(r'^.*$', SOAllocationList.as_view(), name='api-so-allocation-list'),
])),
] ]

View File

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

View File

@ -86,12 +86,17 @@ class ShipSalesOrderForm(HelperForm):
class ReceivePurchaseOrderForm(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: class Meta:
model = PurchaseOrder model = PurchaseOrder
fields = [ fields = [
'location', "location",
] ]
@ -202,6 +207,7 @@ class EditPurchaseOrderLineItemForm(HelperForm):
'quantity', 'quantity',
'reference', 'reference',
'purchase_price', 'purchase_price',
'destination',
'notes', '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 common.settings import currency_code_default
from markdownx.models import MarkdownxField from markdownx.models import MarkdownxField
from mptt.models import TreeForeignKey
from djmoney.models.fields import MoneyField from djmoney.models.fields import MoneyField
@ -672,6 +673,29 @@ class PurchaseOrderLineItem(OrderLineItem):
help_text=_('Unit purchase price'), 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): def remaining(self):
""" Calculate the number of items remaining to be received """ """ Calculate the number of items remaining to be received """
r = self.quantity - self.received r = self.quantity - self.received

View File

@ -17,6 +17,8 @@ from InvenTree.serializers import InvenTreeAttachmentSerializerField
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
from stock.serializers import LocationBriefSerializer
from stock.serializers import StockItemSerializer, LocationSerializer
from .models import PurchaseOrder, PurchaseOrderLineItem from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import PurchaseOrderAttachment, SalesOrderAttachment from .models import PurchaseOrderAttachment, SalesOrderAttachment
@ -41,7 +43,7 @@ class POSerializer(InvenTreeModelSerializer):
""" """
Add extra information to the queryset Add extra information to the queryset
- Number of liens in the PurchaseOrder - Number of lines in the PurchaseOrder
- Overdue status of the PurchaseOrder - Overdue status of the PurchaseOrder
""" """
@ -116,6 +118,8 @@ class POLineItemSerializer(InvenTreeModelSerializer):
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True) purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
destination = LocationBriefSerializer(source='get_destination', read_only=True)
class Meta: class Meta:
model = PurchaseOrderLineItem model = PurchaseOrderLineItem
@ -132,6 +136,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
'purchase_price', 'purchase_price',
'purchase_price_currency', 'purchase_price_currency',
'purchase_price_string', 'purchase_price_string',
'destination',
] ]
@ -232,11 +237,38 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
This includes some fields from the related model objects. This includes some fields from the related model objects.
""" """
location_path = serializers.CharField(source='get_location_path') part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True)
location_id = serializers.IntegerField(source='get_location') order = serializers.PrimaryKeyRelatedField(source='line.order', many=False, read_only=True)
serial = serializers.CharField(source='get_serial') serial = serializers.CharField(source='get_serial', read_only=True)
po = serializers.CharField(source='get_po') quantity = serializers.FloatField(read_only=True)
quantity = serializers.FloatField() location = serializers.PrimaryKeyRelatedField(source='item.location', many=False, read_only=True)
# Extra detail fields
order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True)
part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True)
item_detail = StockItemSerializer(source='item', many=False, read_only=True)
location_detail = LocationSerializer(source='item.location', many=False, read_only=True)
def __init__(self, *args, **kwargs):
order_detail = kwargs.pop('order_detail', False)
part_detail = kwargs.pop('part_detail', False)
item_detail = kwargs.pop('item_detail', False)
location_detail = kwargs.pop('location_detail', False)
super().__init__(*args, **kwargs)
if not order_detail:
self.fields.pop('order_detail')
if not part_detail:
self.fields.pop('part_detail')
if not item_detail:
self.fields.pop('item_detail')
if not location_detail:
self.fields.pop('location_detail')
class Meta: class Meta:
model = SalesOrderAllocation model = SalesOrderAllocation
@ -246,10 +278,14 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
'line', 'line',
'serial', 'serial',
'quantity', 'quantity',
'location_id', 'location',
'location_path', 'location_detail',
'po',
'item', 'item',
'item_detail',
'order',
'order_detail',
'part',
'part_detail',
] ]

View File

@ -117,6 +117,7 @@ $("#po-table").inventreeTable({
part_detail: true, part_detail: true,
}, },
url: "{% url 'api-po-line-list' %}", url: "{% url 'api-po-line-list' %}",
showFooter: true,
columns: [ columns: [
{ {
field: 'pk', field: 'pk',
@ -137,6 +138,9 @@ $("#po-table").inventreeTable({
return '-'; return '-';
} }
}, },
footerFormatter: function() {
return '{% trans "Total" %}'
}
}, },
{ {
field: 'part_detail.description', field: 'part_detail.description',
@ -172,7 +176,14 @@ $("#po-table").inventreeTable({
{ {
sortable: true, sortable: true,
field: 'quantity', 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, sortable: true,
@ -182,6 +193,25 @@ $("#po-table").inventreeTable({
return row.purchase_price_string || row.purchase_price; 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, sortable: true,
field: 'received', field: 'received',
@ -204,6 +234,10 @@ $("#po-table").inventreeTable({
return (progressA < progressB) ? 1 : -1; return (progressA < progressB) ? 1 : -1;
} }
}, },
{
field: 'destination.pathstring',
title: '{% trans "Destination" %}',
},
{ {
field: 'notes', field: 'notes',
title: '{% trans "Notes" %}', title: '{% trans "Notes" %}',

View File

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

View File

@ -81,10 +81,10 @@ function showAllocationSubTable(index, row, element) {
}, },
}, },
{ {
field: 'location_id', field: 'location',
title: 'Location', title: 'Location',
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
return renderLink(row.location_path, `/stock/location/${row.location_id}/`); return renderLink(row.location_path, `/stock/location/${row.location}/`);
}, },
}, },
{ {
@ -199,6 +199,7 @@ $("#so-lines-table").inventreeTable({
detailFormatter: showFulfilledSubTable, detailFormatter: showFulfilledSubTable,
{% endif %} {% endif %}
{% endif %} {% endif %}
showFooter: true,
columns: [ columns: [
{ {
field: 'pk', field: 'pk',
@ -217,7 +218,10 @@ $("#so-lines-table").inventreeTable({
} else { } else {
return '-'; return '-';
} }
} },
footerFormatter: function() {
return '{% trans "Total" %}'
},
}, },
{ {
sortable: true, sortable: true,
@ -228,6 +232,13 @@ $("#so-lines-table").inventreeTable({
sortable: true, sortable: true,
field: 'quantity', 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, sortable: true,
@ -237,6 +248,26 @@ $("#so-lines-table").inventreeTable({
return row.sale_price_string || row.sale_price; 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', field: 'allocated',
{% if order.status == SalesOrderStatus.PENDING %} {% if order.status == SalesOrderStatus.PENDING %}

View File

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

View File

@ -14,7 +14,7 @@ from .models import BomItem
from .models import PartParameterTemplate, PartParameter from .models import PartParameterTemplate, PartParameter
from .models import PartCategoryParameterTemplate from .models import PartCategoryParameterTemplate
from .models import PartTestTemplate from .models import PartTestTemplate
from .models import PartSellPriceBreak from .models import PartSellPriceBreak, PartInternalPriceBreak
from stock.models import StockLocation from stock.models import StockLocation
from company.models import SupplierPart from company.models import SupplierPart
@ -286,6 +286,14 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin):
list_display = ('part', 'quantity', 'price',) list_display = ('part', 'quantity', 'price',)
class PartInternalPriceBreakAdmin(admin.ModelAdmin):
class Meta:
model = PartInternalPriceBreak
list_display = ('part', 'quantity', 'price',)
admin.site.register(Part, PartAdmin) admin.site.register(Part, PartAdmin)
admin.site.register(PartCategory, PartCategoryAdmin) admin.site.register(PartCategory, PartCategoryAdmin)
admin.site.register(PartRelated, PartRelatedAdmin) admin.site.register(PartRelated, PartRelatedAdmin)
@ -297,3 +305,4 @@ admin.site.register(PartParameter, ParameterAdmin)
admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin) admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin)
admin.site.register(PartTestTemplate, PartTestTemplateAdmin) admin.site.register(PartTestTemplate, PartTestTemplateAdmin)
admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin) admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin)
admin.site.register(PartInternalPriceBreak, PartInternalPriceBreakAdmin)

View File

@ -25,7 +25,7 @@ from django.urls import reverse
from .models import Part, PartCategory, BomItem from .models import Part, PartCategory, BomItem
from .models import PartParameter, PartParameterTemplate from .models import PartParameter, PartParameterTemplate
from .models import PartAttachment, PartTestTemplate from .models import PartAttachment, PartTestTemplate
from .models import PartSellPriceBreak from .models import PartSellPriceBreak, PartInternalPriceBreak
from .models import PartCategoryParameterTemplate from .models import PartCategoryParameterTemplate
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
@ -194,6 +194,24 @@ class PartSalePriceList(generics.ListCreateAPIView):
] ]
class PartInternalPriceList(generics.ListCreateAPIView):
"""
API endpoint for list view of PartInternalPriceBreak model
"""
queryset = PartInternalPriceBreak.objects.all()
serializer_class = part_serializers.PartInternalPriceSerializer
permission_required = 'roles.sales_order.show'
filter_backends = [
DjangoFilterBackend
]
filter_fields = [
'part',
]
class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin): class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
""" """
API endpoint for listing (and creating) a PartAttachment (file upload). API endpoint for listing (and creating) a PartAttachment (file upload).
@ -1017,6 +1035,11 @@ part_api_urls = [
url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'), url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
])), ])),
# Base URL for part internal pricing
url(r'^internal-price/', include([
url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
])),
# Base URL for PartParameter API endpoints # Base URL for PartParameter API endpoints
url(r'^parameter/', include([ url(r'^parameter/', include([
url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-param-template-list'), url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-param-template-list'),

View File

@ -0,0 +1,51 @@
# Sell price breaks for parts
# Price breaks for R_2K2_0805
- model: part.partsellpricebreak
pk: 1
fields:
part: 3
quantity: 1
price: 0.15
- model: part.partsellpricebreak
pk: 2
fields:
part: 3
quantity: 10
price: 0.10
# Internal price breaks for parts
# Internal Price breaks for R_2K2_0805
- model: part.partinternalpricebreak
pk: 1
fields:
part: 3
quantity: 1
price: 0.08
- model: part.partinternalpricebreak
pk: 2
fields:
part: 3
quantity: 10
price: 0.05
# Internal Price breaks for C_22N_0805
- model: part.partinternalpricebreak
pk: 3
fields:
part: 5
quantity: 1
price: 1
- model: part.partinternalpricebreak
pk: 4
fields:
part: 5
quantity: 24
price: 0.5

View File

@ -20,7 +20,7 @@ from .models import BomItem
from .models import PartParameterTemplate, PartParameter from .models import PartParameterTemplate, PartParameter
from .models import PartCategoryParameterTemplate from .models import PartCategoryParameterTemplate
from .models import PartTestTemplate from .models import PartTestTemplate
from .models import PartSellPriceBreak from .models import PartSellPriceBreak, PartInternalPriceBreak
class PartModelChoiceField(forms.ModelChoiceField): class PartModelChoiceField(forms.ModelChoiceField):
@ -394,3 +394,19 @@ class EditPartSalePriceBreakForm(HelperForm):
'quantity', 'quantity',
'price', 'price',
] ]
class EditPartInternalPriceBreakForm(HelperForm):
"""
Form for creating / editing a internal price for a part
"""
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
class Meta:
model = PartInternalPriceBreak
fields = [
'part',
'quantity',
'price',
]

View File

@ -0,0 +1,30 @@
# Generated by Django 3.2 on 2021-06-05 14:13
import InvenTree.fields
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import djmoney.models.fields
class Migration(migrations.Migration):
dependencies = [
('part', '0066_bomitem_allow_variants'),
]
operations = [
migrations.CreateModel(
name='PartInternalPriceBreak',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Price break quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Quantity')),
('price_currency', djmoney.models.fields.CurrencyField(choices=[('AUD', 'Australian Dollar'), ('CAD', 'Canadian Dollar'), ('EUR', 'Euro'), ('NZD', 'New Zealand Dollar'), ('GBP', 'Pound Sterling'), ('USD', 'US Dollar'), ('JPY', 'Yen')], default='USD', editable=False, max_length=3)),
('price', djmoney.models.fields.MoneyField(decimal_places=4, default_currency='USD', help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price')),
('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='internalpricebreaks', to='part.part', verbose_name='Part')),
],
options={
'unique_together': {('part', 'quantity')},
},
),
]

View File

@ -1544,7 +1544,7 @@ class Part(MPTTModel):
return (min_price, max_price) return (min_price, max_price)
def get_bom_price_range(self, quantity=1): def get_bom_price_range(self, quantity=1, internal=False):
""" Return the price range of the BOM for this part. """ Return the price range of the BOM for this part.
Adds the minimum price for all components in the BOM. Adds the minimum price for all components in the BOM.
@ -1561,7 +1561,7 @@ class Part(MPTTModel):
print("Warning: Item contains itself in BOM") print("Warning: Item contains itself in BOM")
continue continue
prices = item.sub_part.get_price_range(quantity * item.quantity) prices = item.sub_part.get_price_range(quantity * item.quantity, internal=internal)
if prices is None: if prices is None:
continue continue
@ -1585,7 +1585,7 @@ class Part(MPTTModel):
return (min_price, max_price) return (min_price, max_price)
def get_price_range(self, quantity=1, buy=True, bom=True): def get_price_range(self, quantity=1, buy=True, bom=True, internal=False):
""" Return the price range for this part. This price can be either: """ Return the price range for this part. This price can be either:
@ -1596,8 +1596,13 @@ class Part(MPTTModel):
Minimum of the supplier price or BOM price. If no pricing available, returns None Minimum of the supplier price or BOM price. If no pricing available, returns None
""" """
# only get internal price if set and should be used
if internal and self.has_internal_price_breaks:
internal_price = self.get_internal_price(quantity)
return internal_price, internal_price
buy_price_range = self.get_supplier_price_range(quantity) if buy else None buy_price_range = self.get_supplier_price_range(quantity) if buy else None
bom_price_range = self.get_bom_price_range(quantity) if bom else None bom_price_range = self.get_bom_price_range(quantity, internal=internal) if bom else None
if buy_price_range is None: if buy_price_range is None:
return bom_price_range return bom_price_range
@ -1649,6 +1654,22 @@ class Part(MPTTModel):
price=price price=price
) )
def get_internal_price(self, quantity, moq=True, multiples=True, currency=None):
return common.models.get_price(self, quantity, moq, multiples, currency, break_name='internal_price_breaks')
@property
def has_internal_price_breaks(self):
return self.internal_price_breaks.count() > 0
@property
def internal_price_breaks(self):
""" Return the associated price breaks in the correct order """
return self.internalpricebreaks.order_by('quantity').all()
@property
def internal_unit_pricing(self):
return self.get_internal_price(1)
@transaction.atomic @transaction.atomic
def copy_bom_from(self, other, clear=True, **kwargs): def copy_bom_from(self, other, clear=True, **kwargs):
""" """
@ -1983,6 +2004,21 @@ class PartSellPriceBreak(common.models.PriceBreak):
unique_together = ('part', 'quantity') unique_together = ('part', 'quantity')
class PartInternalPriceBreak(common.models.PriceBreak):
"""
Represents a price break for internally selling this part
"""
part = models.ForeignKey(
Part, on_delete=models.CASCADE,
related_name='internalpricebreaks',
verbose_name=_('Part')
)
class Meta:
unique_together = ('part', 'quantity')
class PartStar(models.Model): class PartStar(models.Model):
""" A PartStar object creates a relationship between a User and a Part. """ A PartStar object creates a relationship between a User and a Part.

View File

@ -17,7 +17,8 @@ from stock.models import StockItem
from .models import (BomItem, Part, PartAttachment, PartCategory, from .models import (BomItem, Part, PartAttachment, PartCategory,
PartParameter, PartParameterTemplate, PartSellPriceBreak, PartParameter, PartParameterTemplate, PartSellPriceBreak,
PartStar, PartTestTemplate, PartCategoryParameterTemplate) PartStar, PartTestTemplate, PartCategoryParameterTemplate,
PartInternalPriceBreak)
class CategorySerializer(InvenTreeModelSerializer): class CategorySerializer(InvenTreeModelSerializer):
@ -100,6 +101,25 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
] ]
class PartInternalPriceSerializer(InvenTreeModelSerializer):
"""
Serializer for internal prices for Part model.
"""
quantity = serializers.FloatField()
price = serializers.CharField()
class Meta:
model = PartInternalPriceBreak
fields = [
'pk',
'part',
'quantity',
'price',
]
class PartThumbSerializer(serializers.Serializer): class PartThumbSerializer(serializers.Serializer):
""" """
Serializer for the 'image' field of the Part model. Serializer for the 'image' field of the Part model.

View File

@ -8,52 +8,43 @@
{% endblock %} {% endblock %}
{% block heading %} {% block heading %}
{% trans "Part Stock Allocations" %} {% trans "Build Order Allocations" %}
{% endblock %} {% endblock %}
{% block details %} {% block details %}
<table class='table table-striped table-condensed' id='build-table'>
<tr> <table class='table table-striped table-condensed' id='build-order-table'></table>
<th>{% trans "Order" %}</th>
<th>{% trans "Stock Item" %}</th>
<th>{% trans "Quantity" %}</th>
</tr>
{% for allocation in part.build_order_allocations %}
<tr>
<td><a href="{% url 'build-detail' allocation.build.id %}">{% trans "Build Order" %}: {{ allocation.build }}</a></td>
<td><a href="{% url 'stock-item-detail' allocation.stock_item.id %}">{% trans "Stock Item" %}: {{ allocation.stock_item }}</a></td>
<td>{% decimal allocation.quantity %}</td>
</tr>
{% endfor %}
{% for allocation in part.sales_order_allocations %}
<tr>
<td><a href="{% url 'so-detail' allocation.line.order.id %}">{% trans "Sales Order" %}: {{ allocation.line.order }}</a></td>
<td><a href="{% url 'stock-item-detail' allocation.item.id %}">{% trans "Stock Item" %}: {{ allocation.item }}</a></td>
<td>{% decimal allocation.quantity %}</td>
</tr>
{% endfor %}
</table>
{% endblock %} {% endblock %}
{% block pre_content_panel %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
<h4>{% trans "Sales Order Allocations" %}</h4>
</div>
<div class='panel-content'>
<table class='table table-striped table-condensed' id='sales-order-table'></table>
</div>
</div>
{% endblock %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$("#build-table").inventreeTable({ loadSalesOrderAllocationTable("#sales-order-table", {
columns: [ params: {
{ part: {{ part.id }},
title: '{% trans "Order" %}', }
sortable: true, });
},
{ loadBuildOrderAllocationTable("#build-order-table", {
title: '{% trans "Stock Item" %}', params: {
sortable: true, part: {{ part.id }},
},
{
title: '{% trans "Quantity" %}',
sortable: true,
} }
]
}); });
{% endblock %} {% endblock %}

View File

@ -0,0 +1,122 @@
{% extends "part/part_base.html" %}
{% load static %}
{% load i18n %}
{% load inventree_extras %}
{% block menubar %}
{% include 'part/navbar.html' with tab='internal-prices' %}
{% endblock %}
{% block heading %}
{% trans "Internal Price Information" %}
{% endblock %}
{% block details %}
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% if show_internal_price and roles.sales_order.view %}
<div id='internal-price-break-toolbar' class='btn-group'>
<button class='btn btn-primary' id='new-internal-price-break' type='button'>
<span class='fas fa-plus-circle'></span> {% trans "Add Internal Price Break" %}
</button>
</div>
<table class='table table-striped table-condensed' id='internal-price-break-table' data-toolbar='#internal-price-break-toolbar'>
</table>
{% else %}
<div class='container-fluid'>
<h3>{% trans "Permission Denied" %}</h3>
<div class='alert alert-danger alert-block'>
{% trans "You do not have permission to view this page." %}
</div>
</div>
{% endif %}
{% endblock %}
{% block js_ready %}
{{ block.super }}
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% if show_internal_price and roles.sales_order.view %}
function reloadPriceBreaks() {
$("#internal-price-break-table").bootstrapTable("refresh");
}
$('#new-internal-price-break').click(function() {
launchModalForm("{% url 'internal-price-break-create' %}",
{
success: reloadPriceBreaks,
data: {
part: {{ part.id }},
}
}
);
});
$('#internal-price-break-table').inventreeTable({
name: 'internalprice',
formatNoMatches: function() { return "{% trans 'No internal price break information found' %}"; },
queryParams: {
part: {{ part.id }},
},
url: "{% url 'api-part-internal-price-list' %}",
onPostBody: function() {
var table = $('#internal-price-break-table');
table.find('.button-internal-price-break-delete').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
`/part/internal-price/${pk}/delete/`,
{
success: reloadPriceBreaks
}
);
});
table.find('.button-internal-price-break-edit').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
`/part/internal-price/${pk}/edit/`,
{
success: reloadPriceBreaks
}
);
});
},
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'quantity',
title: '{% trans "Quantity" %}',
sortable: true,
},
{
field: 'price',
title: '{% trans "Price" %}',
sortable: true,
formatter: function(value, row, index) {
var html = value;
html += `<div class='btn-group float-right' role='group'>`
html += makeIconButton('fa-edit icon-blue', 'button-internal-price-break-edit', row.pk, '{% trans "Edit internal price break" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-internal-price-break-delete', row.pk, '{% trans "Delete internal price break" %}');
html += `</div>`;
return html;
}
},
]
})
{% endif %}
{% endblock %}

View File

@ -2,6 +2,8 @@
{% load static %} {% load static %}
{% load inventree_extras %} {% load inventree_extras %}
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
<ul class='list-group'> <ul class='list-group'>
<li class='list-group-item'> <li class='list-group-item'>
<a href='#' id='part-menu-toggle'> <a href='#' id='part-menu-toggle'>
@ -94,7 +96,13 @@
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% if part.salable and roles.sales_order.view %} {% if show_internal_price and roles.sales_order.view %}
<li class='list-group-item {% if tab == "internal-prices" %}active{% endif %}' title='{% trans "Internal Price Information" %}'>
<a href='{% url "part-internal-prices" part.id %}'>
<span class='menu-tab-icon fas fa-dollar-sign' style='width: 20px;'></span>
{% trans "Internal Price" %}
</a>
</li>
<li class='list-group-item {% if tab == "sales-prices" %}active{% endif %}' title='{% trans "Sales Price Information" %}'> <li class='list-group-item {% if tab == "sales-prices" %}active{% endif %}' title='{% trans "Sales Price Information" %}'>
<a href='{% url "part-sale-prices" part.id %}'> <a href='{% url "part-sale-prices" part.id %}'>
<span class='menu-tab-icon fas fa-dollar-sign sidebar-icon'></span> <span class='menu-tab-icon fas fa-dollar-sign sidebar-icon'></span>

View File

@ -14,8 +14,18 @@
{% block details %} {% block details %}
{% default_currency as currency %} {% 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"> <div class="row"><div class="col col-md-6">
<h4>{% trans "Pricing ranges" %}</h4> <h4>{% trans "Pricing ranges" %}</h4>
@ -77,6 +87,21 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if show_internal_price and roles.sales_order.view %}
{% if total_internal_part_price %}
<tr>
<td><b>{% trans 'Internal Price' %}</b></td>
<td>{% trans 'Unit Cost' %}</td>
<td colspan='2'>{% include "price.html" with price=unit_internal_part_price %}</td>
</tr>
<tr>
<td></td>
<td>{% trans 'Total Cost' %}</td>
<td colspan='2'>{% include "price.html" with price=total_internal_part_price %}</td>
</tr>
{% endif %}
{% endif %}
{% if total_part_price %} {% if total_part_price %}
<tr> <tr>
<td><b>{% trans 'Sale Price' %}</b></td> <td><b>{% trans 'Sale Price' %}</b></td>
@ -110,8 +135,8 @@
{% if price_history %} {% if price_history %}
<hr> <hr>
<h4>{% trans 'Stock Pricing' %}<i class="fas fa-info-circle" title="Shows the prices of stock for this part <h4>{% trans 'Stock Pricing' %}<i class="fas fa-info-circle" title="Shows the purchase prices of stock for this part.
the part single price shown is the current price for that supplier part"></i></h4> The part single price is the current purchase price for that supplier part."></i></h4>
{% if price_history|length > 1 %} {% if price_history|length > 1 %}
<div style="max-width: 99%; min-height: 300px"> <div style="max-width: 99%; min-height: 300px">
<canvas id="StockPriceChart"></canvas> <canvas id="StockPriceChart"></canvas>
@ -157,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 %} {% for line in price_history %}{{ line.price_diff|stringformat:".2f" }},{% endfor %}
], ],
borderWidth: 1, borderWidth: 1,
type: 'line' type: 'line',
hidden: true,
}, },
{ {
label: '{% blocktrans %}Part Single Price - {{currency}}{% endblocktrans %}', label: '{% blocktrans %}Part Single Price - {{currency}}{% endblocktrans %}',
@ -168,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 %} {% for line in price_history %}{{ line.price_part|stringformat:".2f" }},{% endfor %}
], ],
borderWidth: 1, borderWidth: 1,
type: 'line' type: 'line',
hidden: true,
}, },
{% endif %} {% endif %}
{ {
@ -187,18 +214,18 @@ the part single price shown is the current price for that supplier part"></i></h
var bomdata = { var bomdata = {
labels: [{% for line in bom_parts %}'{{ line.name }}',{% endfor %}], labels: [{% for line in bom_parts %}'{{ line.name }}',{% endfor %}],
datasets: [ datasets: [
{% if bom_pie_min %} {
label: 'Price',
data: [{% for line in bom_parts %}{{ line.min_price }},{% endfor %}],
backgroundColor: bom_colors,
},
{% if bom_pie_max %}
{ {
label: 'Max Price', label: 'Max Price',
data: [{% for line in bom_parts %}{{ line.max_price }},{% endfor %}], data: [{% for line in bom_parts %}{{ line.max_price }},{% endfor %}],
backgroundColor: bom_colors, backgroundColor: bom_colors,
}, },
{% endif %} {% endif %}
{
label: 'Price',
data: [{% for line in bom_parts %}{% if bom_pie_min %}{{ line.min_price }}{% else %}{{ line.price }}{% endif%},{% endfor %}],
backgroundColor: bom_colors,
}
] ]
}; };
var BomChart = loadBomChart(document.getElementById('BomChart'), bomdata) var BomChart = loadBomChart(document.getElementById('BomChart'), bomdata)

View File

@ -181,14 +181,27 @@
{% endif %} {% endif %}
{% endif %} {% 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> </table>
</div> </div>
</div> </div>
</div> </div>
{% block pre_content_panel %}
{% endblock %}
<div class='panel panel-default panel-inventree'> <div class='panel panel-default panel-inventree'>
<div class='panel-heading'> <div class='panel-heading'>
<h4> <h4>
{% block heading %} {% block heading %}
@ -202,7 +215,11 @@
<!-- Specific part details go here... --> <!-- Specific part details go here... -->
{% endblock %} {% endblock %}
</div> </div>
</div> </div>
{% block post_content_panel %}
{% endblock %}
{% endblock %} {% endblock %}

View File

@ -3,7 +3,10 @@
{% load i18n inventree_extras %} {% load i18n inventree_extras %}
{% block pre_form_content %} {% block pre_form_content %}
{% default_currency as currency %} {% default_currency as currency %}
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
<table class='table table-striped table-condensed table-price-two'> <table class='table table-striped table-condensed table-price-two'>
<tr> <tr>
<td><b>{% trans 'Part' %}</b></td> <td><b>{% trans 'Part' %}</b></td>
@ -74,6 +77,22 @@
</table> </table>
{% endif %} {% endif %}
{% if show_internal_price and roles.sales_order.view %}
{% if total_internal_part_price %}
<h4>{% trans 'Internal Price' %}</h4>
<table class='table table-striped table-condensed table-price-two'>
<tr>
<td><b>{% trans 'Unit Cost' %}</b></td>
<td>{% include "price.html" with price=unit_internal_part_price %}</td>
</tr>
<tr>
<td><b>{% trans 'Total Cost' %}</b></td>
<td>{% include "price.html" with price=total_internal_part_price %}</td>
</tr>
</table>
{% endif %}
{% endif %}
{% if total_part_price %} {% if total_part_price %}
<h4>{% trans 'Sale Price' %}</h4> <h4>{% trans 'Sale Price' %}</h4>
<table class='table table-striped table-condensed table-price-two'> <table class='table table-striped table-condensed table-price-two'>

View File

@ -210,9 +210,27 @@ def get_color_theme_css(username):
@register.filter @register.filter
def keyvalue(dict, key): def keyvalue(dict, key):
"""
access to key of supplied dict
usage:
{% mydict|keyvalue:mykey %}
"""
return dict[key] return dict[key]
@register.simple_tag()
def call_method(obj, method_name, *args):
"""
enables calling model methods / functions from templates with arguments
usage:
{% call_method model_object 'fnc_name' argument1 %}
"""
method = getattr(obj, method_name)
return method(*args)
@register.simple_tag() @register.simple_tag()
def authorized_owners(group): def authorized_owners(group):
""" Return authorized owners """ """ Return authorized owners """

View File

@ -1,5 +1,6 @@
from django.test import TestCase from django.test import TestCase
import django.core.exceptions as django_exceptions import django.core.exceptions as django_exceptions
from decimal import Decimal
from .models import Part, BomItem from .models import Part, BomItem
@ -11,11 +12,16 @@ class BomItemTest(TestCase):
'part', 'part',
'location', 'location',
'bom', 'bom',
'company',
'supplier_part',
'part_pricebreaks',
'price_breaks',
] ]
def setUp(self): def setUp(self):
self.bob = Part.objects.get(id=100) self.bob = Part.objects.get(id=100)
self.orphan = Part.objects.get(name='Orphan') self.orphan = Part.objects.get(name='Orphan')
self.r1 = Part.objects.get(name='R_2K2_0805')
def test_str(self): def test_str(self):
b = BomItem.objects.get(id=1) b = BomItem.objects.get(id=1)
@ -111,3 +117,10 @@ class BomItemTest(TestCase):
item.validate_hash() item.validate_hash()
self.assertNotEqual(h1, h2) self.assertNotEqual(h1, h2)
def test_pricing(self):
self.bob.get_price(1)
self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(84.5), Decimal(89.5)))
# remove internal price for R_2K2_0805
self.r1.internal_price_breaks.delete()
self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(82.5), Decimal(87.5)))

View File

@ -51,6 +51,7 @@ class PartTest(TestCase):
'category', 'category',
'part', 'part',
'location', 'location',
'part_pricebreaks'
] ]
def setUp(self): def setUp(self):
@ -113,6 +114,22 @@ class PartTest(TestCase):
self.assertTrue(len(matches) > 0) self.assertTrue(len(matches) > 0)
def test_sell_pricing(self):
# check that the sell pricebreaks were loaded
self.assertTrue(self.r1.has_price_breaks)
self.assertEqual(self.r1.price_breaks.count(), 2)
# check that the sell pricebreaks work
self.assertEqual(float(self.r1.get_price(1)), 0.15)
self.assertEqual(float(self.r1.get_price(10)), 1.0)
def test_internal_pricing(self):
# check that the sell pricebreaks were loaded
self.assertTrue(self.r1.has_internal_price_breaks)
self.assertEqual(self.r1.internal_price_breaks.count(), 2)
# check that the sell pricebreaks work
self.assertEqual(float(self.r1.get_internal_price(1)), 0.08)
self.assertEqual(float(self.r1.get_internal_price(10)), 0.5)
class TestTemplateTest(TestCase): class TestTemplateTest(TestCase):

View File

@ -29,6 +29,12 @@ sale_price_break_urls = [
url(r'^(?P<pk>\d+)/delete/', views.PartSalePriceBreakDelete.as_view(), name='sale-price-break-delete'), url(r'^(?P<pk>\d+)/delete/', views.PartSalePriceBreakDelete.as_view(), name='sale-price-break-delete'),
] ]
internal_price_break_urls = [
url(r'^new/', views.PartInternalPriceBreakCreate.as_view(), name='internal-price-break-create'),
url(r'^(?P<pk>\d+)/edit/', views.PartInternalPriceBreakEdit.as_view(), name='internal-price-break-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PartInternalPriceBreakDelete.as_view(), name='internal-price-break-delete'),
]
part_parameter_urls = [ part_parameter_urls = [
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'), url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'), url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
@ -65,6 +71,7 @@ part_detail_urls = [
url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'), url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'),
url(r'^sales-orders/', views.PartDetail.as_view(template_name='part/sales_orders.html'), name='part-sales-orders'), url(r'^sales-orders/', views.PartDetail.as_view(template_name='part/sales_orders.html'), name='part-sales-orders'),
url(r'^sale-prices/', views.PartDetail.as_view(template_name='part/sale_prices.html'), name='part-sale-prices'), url(r'^sale-prices/', views.PartDetail.as_view(template_name='part/sale_prices.html'), name='part-sale-prices'),
url(r'^internal-prices/', views.PartDetail.as_view(template_name='part/internal_prices.html'), name='part-internal-prices'),
url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'), url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'),
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'), url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
url(r'^related-parts/?', views.PartDetail.as_view(template_name='part/related.html'), name='part-related'), url(r'^related-parts/?', views.PartDetail.as_view(template_name='part/related.html'), name='part-related'),
@ -149,6 +156,9 @@ part_urls = [
# Part price breaks # Part price breaks
url(r'^sale-price/', include(sale_price_break_urls)), url(r'^sale-price/', include(sale_price_break_urls)),
# Part internal price breaks
url(r'^internal-price/', include(internal_price_break_urls)),
# Part test templates # Part test templates
url(r'^test-template/', include([ url(r'^test-template/', include([
url(r'^new/', views.PartTestTemplateCreate.as_view(), name='part-test-template-create'), url(r'^new/', views.PartTestTemplateCreate.as_view(), name='part-test-template-create'),

View File

@ -37,7 +37,7 @@ from .models import PartCategoryParameterTemplate
from .models import BomItem from .models import BomItem
from .models import match_part_names from .models import match_part_names
from .models import PartTestTemplate from .models import PartTestTemplate
from .models import PartSellPriceBreak from .models import PartSellPriceBreak, PartInternalPriceBreak
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from company.models import SupplierPart from company.models import SupplierPart
@ -1013,17 +1013,26 @@ class PartPricingView(PartDetail):
ctx['price_history'] = ret ctx['price_history'] = ret
# BOM Information for Pie-Chart # BOM Information for Pie-Chart
bom_items = [{'name': str(a.sub_part), 'price': a.sub_part.get_price_range(quantity), 'q': a.quantity} for a in part.bom_items.all()] if part.has_bom:
if [True for a in bom_items if len(set(a['price'])) == 2]: ctx_bom_parts = []
ctx['bom_parts'] = [{ # iterate over all bom-items
'name': a['name'], for item in part.bom_items.all():
'min_price': str((a['price'][0] * a['q']) / quantity), ctx_item = {'name': str(item.sub_part)}
'max_price': str((a['price'][1] * a['q']) / quantity)} for a in bom_items] price, qty = item.sub_part.get_price_range(quantity), item.quantity
ctx['bom_pie_min'] = True
else: price_min, price_max = 0, 0
ctx['bom_parts'] = [{ if price: # check if price available
'name': a['name'], price_min = str((price[0] * qty) / quantity)
'price': str((a['price'][0] * a['q']) / quantity)} for a in bom_items] if len(set(price)) == 2: # min and max-price present
price_max = str((price[1] * qty) / quantity)
ctx['bom_pie_max'] = True # enable showing max prices in bom
ctx_item['max_price'] = price_min
ctx_item['min_price'] = price_max if price_max else price_min
ctx_bom_parts.append(ctx_item)
# add to global context
ctx['bom_parts'] = ctx_bom_parts
return ctx return ctx
@ -2272,7 +2281,8 @@ class PartPricing(AjaxView):
# BOM pricing information # BOM pricing information
if part.bom_count > 0: if part.bom_count > 0:
bom_price = part.get_bom_price_range(quantity) use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
bom_price = part.get_bom_price_range(quantity, internal=use_internal)
if bom_price is not None: if bom_price is not None:
min_bom_price, max_bom_price = bom_price min_bom_price, max_bom_price = bom_price
@ -2294,6 +2304,12 @@ class PartPricing(AjaxView):
ctx['max_total_bom_price'] = max_bom_price ctx['max_total_bom_price'] = max_bom_price
ctx['max_unit_bom_price'] = max_unit_bom_price ctx['max_unit_bom_price'] = max_unit_bom_price
# internal part pricing information
internal_part_price = part.get_internal_price(quantity)
if internal_part_price is not None:
ctx['total_internal_part_price'] = round(internal_part_price, 3)
ctx['unit_internal_part_price'] = round(internal_part_price / quantity, 3)
# part pricing information # part pricing information
part_price = part.get_price(quantity) part_price = part.get_price(quantity)
if part_price is not None: if part_price is not None:
@ -2961,3 +2977,29 @@ class PartSalePriceBreakDelete(AjaxDeleteView):
model = PartSellPriceBreak model = PartSellPriceBreak
ajax_form_title = _("Delete Price Break") ajax_form_title = _("Delete Price Break")
ajax_template_name = "modal_delete_form.html" ajax_template_name = "modal_delete_form.html"
class PartInternalPriceBreakCreate(PartSalePriceBreakCreate):
""" View for creating a internal price break for a part """
model = PartInternalPriceBreak
form_class = part_forms.EditPartInternalPriceBreakForm
ajax_form_title = _('Add Internal Price Break')
permission_required = 'roles.sales_order.add'
class PartInternalPriceBreakEdit(PartSalePriceBreakEdit):
""" View for editing a internal price break """
model = PartInternalPriceBreak
form_class = part_forms.EditPartInternalPriceBreakForm
ajax_form_title = _('Edit Internal Price Break')
permission_required = 'roles.sales_order.change'
class PartInternalPriceBreakDelete(PartSalePriceBreakDelete):
""" View for deleting a internal price break """
model = PartInternalPriceBreak
ajax_form_title = _("Delete Internal Price Break")
permission_required = 'roles.sales_order.delete'

View File

@ -161,6 +161,13 @@ class StockItemSerializer(InvenTreeModelSerializer):
required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False) 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): def __init__(self, *args, **kwargs):
part_detail = kwargs.pop('part_detail', False) part_detail = kwargs.pop('part_detail', False)
@ -215,6 +222,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'tracking_items', 'tracking_items',
'uid', 'uid',
'updated', 'updated',
'purchase_price',
] ]
""" These fields are read-only in this context. """ These fields are read-only in this context.

View File

@ -34,6 +34,9 @@
{% include "InvenTree/settings/setting.html" with key="PART_COPY_PARAMETERS" %} {% include "InvenTree/settings/setting.html" with key="PART_COPY_PARAMETERS" %}
{% include "InvenTree/settings/setting.html" with key="PART_COPY_TESTS" %} {% include "InvenTree/settings/setting.html" with key="PART_COPY_TESTS" %}
{% include "InvenTree/settings/setting.html" with key="PART_CATEGORY_PARAMETERS" %} {% include "InvenTree/settings/setting.html" with key="PART_CATEGORY_PARAMETERS" %}
<tr><td colspan='5'></td></tr>
{% include "InvenTree/settings/setting.html" with key="PART_INTERNAL_PRICE" %}
{% include "InvenTree/settings/setting.html" with key="PART_BOM_USE_INTERNAL_PRICE" %}
</tbody> </tbody>
</table> </table>

View File

@ -155,6 +155,88 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
} }
function loadBuildOrderAllocationTable(table, options={}) {
/**
* Load a table showing all the BuildOrder allocations for a given part
*/
options.params['part_detail'] = true;
options.params['build_detail'] = true;
options.params['location_detail'] = true;
var filters = loadTableFilters("buildorderallocation");
for (var key in options.params) {
filters[key] = options.params[key];
}
setupFilterList("buildorderallocation", $(table));
$(table).inventreeTable({
url: '{% url "api-build-item-list" %}',
queryParams: filters,
name: 'buildorderallocation',
groupBy: false,
search: false,
paginationVAlign: 'bottom',
original: options.params,
formatNoMatches: function() {
return '{% trans "No build order allocations found" %}'
},
columns: [
{
field: 'pk',
visible: false,
switchable: false,
},
{
field: 'build',
switchable: false,
title: '{% trans "Build Order" %}',
formatter: function(value, row) {
var prefix = "{% settings_value 'BUILDORDER_REFERENCE_PREFIX' %}";
var ref = `${prefix}${row.build_detail.reference}`;
return renderLink(ref, `/build/${row.build}/`);
}
},
{
field: 'item',
title: '{% trans "Stock Item" %}',
formatter: function(value, row) {
// Render a link to the particular stock item
var link = `/stock/item/${row.stock_item}/`;
var text = `{% trans "Stock Item" %} ${row.stock_item}`;
return renderLink(text, link);
}
},
{
field: 'location',
title: '{% trans "Location" %}',
formatter: function(value, row) {
if (!value) {
return '{% trans "Location not specified" %}';
}
var link = `/stock/location/${value}`;
var text = row.location_detail.description;
return renderLink(text, link);
}
},
{
field: 'quantity',
title: '{% trans "Quantity" %}',
}
]
});
}
function loadBuildOutputAllocationTable(buildInfo, output, options={}) { function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
/* /*
* Load the "allocation table" for a particular build output. * Load the "allocation table" for a particular build output.
@ -347,6 +429,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
var params = { var params = {
build: buildId, build: buildId,
part_detail: true,
location_detail: true,
} }
if (output) { if (output) {
@ -466,8 +550,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
title: '{% trans "Part" %}', title: '{% trans "Part" %}',
formatter: function(value, row) { formatter: function(value, row) {
var html = imageHoverIcon(row.part_thumb); var html = imageHoverIcon(row.part_detail.thumbnail);
html += renderLink(row.part_name, `/part/${value}/`); html += renderLink(row.part_detail.full_name, `/part/${value}/`);
return html; return html;
} }
}, },

View File

@ -775,7 +775,9 @@ function handleModalForm(url, options) {
} }
// Form was returned, invalid! // Form was returned, invalid!
else { else {
if (!response.hideErrorMessage && !options.hideErrorMessage) {
// Disable error message with option or response
if (!options.hideErrorMessage && !response.hideErrorMessage) {
var warningDiv = $(modal).find('#form-validation-warning'); var warningDiv = $(modal).find('#form-validation-warning');
warningDiv.css('display', 'block'); warningDiv.css('display', 'block');
} }
@ -791,13 +793,16 @@ function handleModalForm(url, options) {
attachSecondaries(modal, options.secondary); attachSecondaries(modal, options.secondary);
} }
// Set modal title with response
if (response.title) { if (response.title) {
modalSetTitle(modal, response.title); modalSetTitle(modal, response.title);
} }
if (response.buttons) {
// Clean custom action buttons // Clean custom action buttons
$(modal).find('#modal-footer-buttons').html(''); $(modal).find('#modal-footer-buttons').html('');
// Add custom action buttons with response
if (response.buttons) {
attachButtons(modal, response.buttons); attachButtons(modal, response.buttons);
} }
} }
@ -846,6 +851,7 @@ function launchModalForm(url, options = {}) {
* secondary - List of secondary modals to attach * secondary - List of secondary modals to attach
* callback - List of callback functions to attach to inputs * callback - List of callback functions to attach to inputs
* focus - Select which field to focus on by default * focus - Select which field to focus on by default
* buttons - additional buttons that should be added as array with [name, title]
*/ */
var modal = options.modal || '#modal-form'; var modal = options.modal || '#modal-form';
@ -905,6 +911,11 @@ function launchModalForm(url, options = {}) {
attachButtons(modal, options.buttons); attachButtons(modal, options.buttons);
} }
// Add custom buttons from response
if (response.buttons) {
attachButtons(modal, response.buttons);
}
} else { } else {
$(modal).modal('hide'); $(modal).modal('hide');
showAlertDialog('{% trans "Invalid server response" %}', '{% trans "JSON response missing form data" %}'); showAlertDialog('{% trans "Invalid server response" %}', '{% trans "JSON response missing form data" %}');

View File

@ -310,3 +310,88 @@ function loadSalesOrderTable(table, options) {
], ],
}); });
} }
function loadSalesOrderAllocationTable(table, options={}) {
/**
* Load a table with SalesOrderAllocation items
*/
options.params = options.params || {};
options.params['location_detail'] = true;
options.params['part_detail'] = true;
options.params['item_detail'] = true;
options.params['order_detail'] = true;
var filters = loadTableFilters("salesorderallocation");
for (var key in options.params) {
filters[key] = options.params[key];
}
setupFilterList("salesorderallocation", $(table));
$(table).inventreeTable({
url: '{% url "api-so-allocation-list" %}',
queryParams: filters,
name: 'salesorderallocation',
groupBy: false,
search: false,
paginationVAlign: 'bottom',
original: options.params,
formatNoMatches: function() { return '{% trans "No sales order allocations found" %}'; },
columns: [
{
field: 'pk',
visible: false,
switchable: false,
},
{
field: 'order',
switchable: false,
title: '{% trans "Order" %}',
switchable: false,
formatter: function(value, row) {
var prefix = "{% settings_value 'SALESORDER_REFERENCE_PREFIX' %}";
var ref = `${prefix}${row.order_detail.reference}`;
return renderLink(ref, `/order/sales-order/${row.order}/`);
}
},
{
field: 'item',
title: '{% trans "Stock Item" %}',
formatter: function(value, row) {
// Render a link to the particular stock item
var link = `/stock/item/${row.item}/`;
var text = `{% trans "Stock Item" %} ${row.item}`;
return renderLink(text, link);
}
},
{
field: 'location',
title: '{% trans "Location" %}',
formatter: function(value, row) {
if (!value) {
return '{% trans "Location not specified" %}';
}
var link = `/stock/location/${value}`;
var text = row.location_detail.description;
return renderLink(text, link);
}
},
{
field: 'quantity',
title: '{% trans "Quantity" %}',
}
]
});
}

View File

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

View File

@ -135,7 +135,7 @@ $.fn.inventreeTable = function(options) {
// Pagingation options (can be server-side or client-side as specified by the caller) // Pagingation options (can be server-side or client-side as specified by the caller)
options.pagination = true; options.pagination = true;
options.paginationVAlign = 'both'; options.paginationVAlign = options.paginationVAlign || 'both';
options.pageSize = inventreeLoad(varName, 25); options.pageSize = inventreeLoad(varName, 25);
options.pageList = [25, 50, 100, 250, 'all']; options.pageList = [25, 50, 100, 250, 'all'];
options.totalField = 'count'; options.totalField = 'count';

View File

@ -77,6 +77,7 @@ class RuleSet(models.Model):
'part_bomitem', 'part_bomitem',
'part_partattachment', 'part_partattachment',
'part_partsellpricebreak', 'part_partsellpricebreak',
'part_partinternalpricebreak',
'part_parttesttemplate', 'part_parttesttemplate',
'part_partparametertemplate', 'part_partparametertemplate',
'part_partparameter', 'part_partparameter',

View File

@ -7,6 +7,8 @@ ARG branch="master"
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
# InvenTree key settings # InvenTree key settings
# The INVENTREE_HOME directory is where the InvenTree source repository will be located
ENV INVENTREE_HOME="/home/inventree" ENV INVENTREE_HOME="/home/inventree"
# GitHub settings # GitHub settings
@ -17,10 +19,9 @@ ENV INVENTREE_LOG_LEVEL="INFO"
ENV INVENTREE_DOCKER="true" ENV INVENTREE_DOCKER="true"
# InvenTree paths # InvenTree paths
ENV INVENTREE_SRC_DIR="${INVENTREE_HOME}/src" ENV INVENTREE_MNG_DIR="${INVENTREE_HOME}/InvenTree"
ENV INVENTREE_MNG_DIR="${INVENTREE_SRC_DIR}/InvenTree"
ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/data" ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/data"
ENV INVENTREE_STATIC_ROOT="${INVENTREE_HOME}/static" ENV INVENTREE_STATIC_ROOT="${INVENTREE_DATA_DIR}/static"
ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media" ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media"
ENV INVENTREE_CONFIG_FILE="${INVENTREE_DATA_DIR}/config.yaml" ENV INVENTREE_CONFIG_FILE="${INVENTREE_DATA_DIR}/config.yaml"
@ -44,8 +45,6 @@ RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup
WORKDIR ${INVENTREE_HOME} WORKDIR ${INVENTREE_HOME}
RUN mkdir -p ${INVENTREE_STATIC_ROOT}
# Install required system packages # Install required system packages
RUN apk add --no-cache git make bash \ RUN apk add --no-cache git make bash \
gcc libgcc g++ libstdc++ \ gcc libgcc g++ libstdc++ \
@ -78,37 +77,40 @@ RUN pip install --no-cache-dir -U gunicorn
FROM base as production FROM base as production
# Clone source code # Clone source code
RUN echo "Downloading InvenTree from ${INVENTREE_REPO}" RUN echo "Downloading InvenTree from ${INVENTREE_REPO}"
RUN git clone --branch ${INVENTREE_BRANCH} --depth 1 ${INVENTREE_REPO} ${INVENTREE_SRC_DIR} RUN git clone --branch ${INVENTREE_BRANCH} --depth 1 ${INVENTREE_REPO} ${INVENTREE_HOME}
# Install InvenTree packages # Install InvenTree packages
RUN pip install --no-cache-dir -U -r ${INVENTREE_SRC_DIR}/requirements.txt RUN pip install --no-cache-dir -U -r ${INVENTREE_HOME}/requirements.txt
# Copy gunicorn config file # Copy gunicorn config file
COPY gunicorn.conf.py ${INVENTREE_HOME}/gunicorn.conf.py COPY gunicorn.conf.py ${INVENTREE_HOME}/gunicorn.conf.py
# Copy startup scripts # Copy startup scripts
COPY start_prod_server.sh ${INVENTREE_SRC_DIR}/start_prod_server.sh COPY start_prod_server.sh ${INVENTREE_HOME}/start_prod_server.sh
COPY start_worker.sh ${INVENTREE_SRC_DIR}/start_worker.sh COPY start_prod_worker.sh ${INVENTREE_HOME}/start_prod_worker.sh
RUN chmod 755 ${INVENTREE_SRC_DIR}/start_prod_server.sh RUN chmod 755 ${INVENTREE_HOME}/start_prod_server.sh
RUN chmod 755 ${INVENTREE_SRC_DIR}/start_worker.sh RUN chmod 755 ${INVENTREE_HOME}/start_prod_worker.sh
# exec commands should be executed from the "src" directory WORKDIR ${INVENTREE_HOME}
WORKDIR ${INVENTREE_SRC_DIR}
# Let us begin # Let us begin
CMD ["bash", "./start_prod_server.sh"] CMD ["bash", "./start_prod_server.sh"]
FROM base as dev FROM base as dev
# The development image requires the source code to be mounted to /home/inventree/src/ # The development image requires the source code to be mounted to /home/inventree/
# So from here, we don't actually "do" anything # So from here, we don't actually "do" anything, apart from some file management
WORKDIR ${INVENTREE_SRC_DIR} ENV INVENTREE_DEV_DIR = "${INVENTREE_HOME}/dev"
COPY start_dev_server.sh ${INVENTREE_HOME}/start_dev_server.sh # Override default path settings
COPY start_dev_worker.sh ${INVENTREE_HOME}/start_dev_worker.sh ENV INVENTREE_STATIC_ROOT="${INVENTREE_DEV_DIR}/static"
RUN chmod 755 ${INVENTREE_HOME}/start_dev_server.sh ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DEV_DIR}/media"
RUN chmod 755 ${INVENTREE_HOME}/start_dev_worker.sh ENV INVENTREE_CONFIG_FILE="${INVENTREE_DEV_DIR}/config.yaml"
ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DEV_DIR}/secret_key.txt"
CMD ["bash", "/home/inventree/start_dev_server.sh"] WORKDIR ${INVENTREE_HOME}
# Launch the development server
CMD ["bash", "/home/inventree/docker/start_dev_server.sh"]

View File

@ -1,7 +1,9 @@
INVENTREE_DB_ENGINE=sqlite3 INVENTREE_DB_ENGINE=sqlite3
INVENTREE_DB_NAME=/home/inventree/src/inventree_docker_dev.sqlite3 INVENTREE_DB_NAME=/home/inventree/dev/inventree_db.sqlite3
INVENTREE_MEDIA_ROOT=/home/inventree/src/inventree_media INVENTREE_MEDIA_ROOT=/home/inventree/dev/media
INVENTREE_STATIC_ROOT=/home/inventree/src/inventree_static INVENTREE_STATIC_ROOT=/home/inventree/dev/static
INVENTREE_CONFIG_FILE=/home/inventree/src/config.yaml INVENTREE_CONFIG_FILE=/home/inventree/dev/config.yaml
INVENTREE_SECRET_KEY_FILE=/home/inventree/src/secret_key.txt INVENTREE_SECRET_KEY_FILE=/home/inventree/dev/secret_key.txt
INVENTREE_DEBUG=true INVENTREE_DEBUG=true
INVENTREE_WEB_ADDR=0.0.0.0
INVENTREE_WEB_PORT=8000

View File

@ -13,8 +13,8 @@ version: "3.8"
services: services:
# InvenTree web server services # InvenTree web server services
# Uses gunicorn as the web server # Uses gunicorn as the web server
inventree-server: inventree-dev-server:
container_name: inventree-server container_name: inventree-dev-server
build: build:
context: . context: .
target: dev target: dev
@ -22,7 +22,7 @@ services:
- 8000:8000 - 8000:8000
volumes: volumes:
# Ensure you specify the location of the 'src' directory at the end of this file # Ensure you specify the location of the 'src' directory at the end of this file
- src:/home/inventree/src - src:/home/inventree
env_file: env_file:
# Environment variables required for the dev server are configured in dev-config.env # Environment variables required for the dev server are configured in dev-config.env
- dev-config.env - dev-config.env
@ -30,24 +30,24 @@ services:
restart: unless-stopped restart: unless-stopped
# Background worker process handles long-running or periodic tasks # Background worker process handles long-running or periodic tasks
inventree-worker: inventree-dev-worker:
container_name: inventree-worker container_name: inventree-dev-worker
build: build:
context: . context: .
target: dev target: dev
entrypoint: /home/inventree/start_dev_worker.sh entrypoint: /home/inventree/docker/start_dev_worker.sh
depends_on: depends_on:
- inventree-server - inventree-dev-server
volumes: volumes:
# Ensure you specify the location of the 'src' directory at the end of this file # Ensure you specify the location of the 'src' directory at the end of this file
- src:/home/inventree/src - src:/home/inventree
env_file: env_file:
# Environment variables required for the dev server are configured in dev-config.env # Environment variables required for the dev server are configured in dev-config.env
- dev-config.env - dev-config.env
restart: unless-stopped restart: unless-stopped
volumes: volumes:
# NOTE: Change /path/to/src to a directory on your local machine, where the InvenTree source code is located # NOTE: Change "../" to a directory on your local machine, where the InvenTree source code is located
# Persistent data, stored external to the container(s) # Persistent data, stored external to the container(s)
src: src:
driver: local driver: local
@ -55,5 +55,5 @@ volumes:
type: none type: none
o: bind o: bind
# This directory specified where InvenTree source code is stored "outside" the docker containers # This directory specified where InvenTree source code is stored "outside" the docker containers
# Note: This directory must conatin the file *manage.py* # By default, this directory is one level above the "docker" directory
device: /path/to/inventree/src device: ../

View File

@ -30,6 +30,7 @@ services:
- POSTGRES_USER=pguser - POSTGRES_USER=pguser
- POSTGRES_PASSWORD=pgpassword - POSTGRES_PASSWORD=pgpassword
volumes: volumes:
# Map 'data' volume such that postgres database is stored externally
- data:/var/lib/postgresql/data/ - data:/var/lib/postgresql/data/
restart: unless-stopped restart: unless-stopped
@ -43,8 +44,8 @@ services:
depends_on: depends_on:
- inventree-db - inventree-db
volumes: volumes:
# Data volume must map to /home/inventree/data
- data:/home/inventree/data - data:/home/inventree/data
- static:/home/inventree/static
environment: environment:
# Default environment variables are configured to match the 'db' container # Default environment variables are configured to match the 'db' container
# Note: If you change the database image, these will need to be adjusted # Note: If you change the database image, these will need to be adjusted
@ -61,13 +62,13 @@ services:
inventree-worker: inventree-worker:
container_name: inventree-worker container_name: inventree-worker
image: inventree/inventree:latest image: inventree/inventree:latest
entrypoint: ./start_worker.sh entrypoint: ./start_prod_worker.sh
depends_on: depends_on:
- inventree-db - inventree-db
- inventree-server - inventree-server
volumes: volumes:
# Data volume must map to /home/inventree/data
- data:/home/inventree/data - data:/home/inventree/data
- static:/home/inventree/static
environment: environment:
# Default environment variables are configured to match the 'db' container # Default environment variables are configured to match the 'db' container
# Note: If you change the database image, these will need to be adjusted # Note: If you change the database image, these will need to be adjusted
@ -81,7 +82,8 @@ services:
restart: unless-stopped restart: unless-stopped
# nginx acts as a reverse proxy # nginx acts as a reverse proxy
# static files are served by nginx # static files are served directly by nginx
# media files are served by nginx, although authentication is redirected to inventree-server
# web requests are redirected to gunicorn # web requests are redirected to gunicorn
# NOTE: You will need to provide a working nginx.conf file! # NOTE: You will need to provide a working nginx.conf file!
inventree-proxy: inventree-proxy:
@ -93,11 +95,11 @@ services:
# Change "1337" to the port that you want InvenTree web server to be available on # Change "1337" to the port that you want InvenTree web server to be available on
- 1337:80 - 1337:80
volumes: volumes:
# Provide nginx.conf file to the container # Provide ./nginx.conf file to the container
# Refer to the provided example file as a starting point # Refer to the provided example file as a starting point
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
# Static data volume is mounted to /var/www/static # nginx proxy needs access to static and media files
- static:/var/www/static:ro - data:/var/www
restart: unless-stopped restart: unless-stopped
volumes: volumes:
@ -111,5 +113,3 @@ volumes:
# This directory specified where InvenTree data are stored "outside" the docker containers # This directory specified where InvenTree data are stored "outside" the docker containers
# Change this path to a local system path where you want InvenTree data stored # Change this path to a local system path where you want InvenTree data stored
device: /path/to/data device: /path/to/data
# Static files, shared between containers
static:

View File

@ -1,3 +1,4 @@
server { server {
# Listen for connection on (internal) port 80 # Listen for connection on (internal) port 80
@ -34,4 +35,23 @@ server {
add_header Cache-Control "public"; add_header Cache-Control "public";
} }
# Redirect any requests for media files
location /media/ {
alias /var/www/media/;
# Media files require user authentication
auth_request /auth;
}
# Use the 'user' API endpoint for auth
location /auth {
internal;
proxy_pass http://inventree-server:8000/auth/;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
}
} }

View File

@ -16,21 +16,22 @@ if test -f "$INVENTREE_CONFIG_FILE"; then
echo "$INVENTREE_CONFIG_FILE exists - skipping" echo "$INVENTREE_CONFIG_FILE exists - skipping"
else else
echo "Copying config file to $INVENTREE_CONFIG_FILE" echo "Copying config file to $INVENTREE_CONFIG_FILE"
cp $INVENTREE_SRC_DIR/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE cp $INVENTREE_HOME/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE
fi fi
# Setup a virtual environment # Setup a virtual environment (within the "dev" directory)
python3 -m venv inventree-docker-dev python3 -m venv ./dev/env
source inventree-docker-dev/bin/activate # Activate the virtual environment
source ./dev/env/bin/activate
echo "Installing required packages..." echo "Installing required packages..."
pip install --no-cache-dir -U -r ${INVENTREE_SRC_DIR}/requirements.txt pip install --no-cache-dir -U -r ${INVENTREE_HOME}/requirements.txt
echo "Starting InvenTree server..." echo "Starting InvenTree server..."
# Wait for the database to be ready # Wait for the database to be ready
cd $INVENTREE_MNG_DIR cd ${INVENTREE_HOME}/InvenTree
python manage.py wait_for_db python manage.py wait_for_db
sleep 10 sleep 10
@ -45,4 +46,4 @@ python manage.py migrate --run-syncdb || exit 1
python manage.py clearsessions || exit 1 python manage.py clearsessions || exit 1
# Launch a development server # Launch a development server
python manage.py runserver 0.0.0.0:$INVENTREE_WEB_PORT python manage.py runserver ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}

View File

@ -2,15 +2,15 @@
echo "Starting InvenTree worker..." echo "Starting InvenTree worker..."
cd $INVENTREE_SRC_DIR cd $INVENTREE_HOME
# Activate virtual environment # Activate virtual environment
source inventree-docker-dev/bin/activate source ./dev/env/bin/activate
sleep 5 sleep 5
# Wait for the database to be ready # Wait for the database to be ready
cd $INVENTREE_MNG_DIR cd InvenTree
python manage.py wait_for_db python manage.py wait_for_db
sleep 10 sleep 10

View File

@ -16,7 +16,7 @@ if test -f "$INVENTREE_CONFIG_FILE"; then
echo "$INVENTREE_CONFIG_FILE exists - skipping" echo "$INVENTREE_CONFIG_FILE exists - skipping"
else else
echo "Copying config file to $INVENTREE_CONFIG_FILE" echo "Copying config file to $INVENTREE_CONFIG_FILE"
cp $INVENTREE_SRC_DIR/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE cp $INVENTREE_HOME/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE
fi fi
echo "Starting InvenTree server..." echo "Starting InvenTree server..."

View File

@ -1,7 +1,7 @@
invoke>=1.4.0 # Invoke build tool invoke>=1.4.0 # Invoke build tool
wheel>=0.34.2 # Wheel wheel>=0.34.2 # Wheel
Django==3.2.1 # Django package Django==3.2.4 # Django package
pillow==8.1.1 # Image manipulation pillow==8.2.0 # Image manipulation
djangorestframework==3.12.4 # DRF framework djangorestframework==3.12.4 # DRF framework
django-cors-headers==3.2.0 # CORS headers extension for DRF django-cors-headers==3.2.0 # CORS headers extension for DRF
django-filter==2.4.0 # Extended filtering options django-filter==2.4.0 # Extended filtering options

View File

@ -129,6 +129,14 @@ def wait(c):
manage(c, "wait_for_db") manage(c, "wait_for_db")
@task
def rebuild(c):
"""
Rebuild database models with MPTT structures
"""
manage(c, "rebuild_models")
@task @task
def migrate(c): def migrate(c):
""" """
@ -282,7 +290,7 @@ def export_records(c, filename='data.json'):
tmpfile = f"{filename}.tmp" tmpfile = f"{filename}.tmp"
cmd = f"dumpdata --indent 2 --output {tmpfile} {content_excludes()}" cmd = f"dumpdata --indent 2 --output '{tmpfile}' {content_excludes()}"
# Dump data to temporary file # Dump data to temporary file
manage(c, cmd, pty=True) manage(c, cmd, pty=True)
@ -311,7 +319,7 @@ def export_records(c, filename='data.json'):
print("Data export completed") print("Data export completed")
@task(help={'filename': 'Input filename'}) @task(help={'filename': 'Input filename'}, post=[rebuild])
def import_records(c, filename='data.json'): def import_records(c, filename='data.json'):
""" """
Import database records from a file Import database records from a file
@ -348,13 +356,13 @@ def import_records(c, filename='data.json'):
with open(tmpfile, "w") as f_out: with open(tmpfile, "w") as f_out:
f_out.write(json.dumps(data, indent=2)) f_out.write(json.dumps(data, indent=2))
cmd = f"loaddata {tmpfile} -i {content_excludes()}" cmd = f"loaddata '{tmpfile}' -i {content_excludes()}"
manage(c, cmd, pty=True) manage(c, cmd, pty=True)
print("Data import completed") print("Data import completed")
@task @task(post=[rebuild])
def import_fixtures(c): def import_fixtures(c):
""" """
Import fixture data into the database. Import fixture data into the database.