mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into part-import
This commit is contained in:
parent
413fa2e842
commit
59e6cc1a10
5
.gitignore
vendored
5
.gitignore
vendored
@ -61,4 +61,7 @@ secret_key.txt
|
||||
|
||||
# Coverage reports
|
||||
.coverage
|
||||
htmlcov/
|
||||
htmlcov/
|
||||
|
||||
# Development files
|
||||
dev/
|
60
InvenTree/InvenTree/management/commands/rebuild_models.py
Normal file
60
InvenTree/InvenTree/management/commands/rebuild_models.py
Normal 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")
|
@ -26,10 +26,9 @@ def canAppAccessDatabase():
|
||||
'flush',
|
||||
'loaddata',
|
||||
'dumpdata',
|
||||
'makemirations',
|
||||
'makemigrations',
|
||||
'migrate',
|
||||
'check',
|
||||
'mediarestore',
|
||||
'shell',
|
||||
'createsuperuser',
|
||||
'wait_for_db',
|
||||
|
@ -98,7 +98,7 @@ DOCKER = _is_true(get_setting(
|
||||
# Configure logging settings
|
||||
log_level = get_setting(
|
||||
'INVENTREE_LOG_LEVEL',
|
||||
CONFIG.get('log_level', 'DEBUG')
|
||||
CONFIG.get('log_level', 'WARNING')
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
@ -192,7 +192,7 @@ STATIC_URL = '/static/'
|
||||
STATIC_ROOT = os.path.abspath(
|
||||
get_setting(
|
||||
'INVENTREE_STATIC_ROOT',
|
||||
CONFIG.get('static_root', '/home/inventree/static')
|
||||
CONFIG.get('static_root', '/home/inventree/data/static')
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -37,6 +37,7 @@ from django.conf.urls.static import static
|
||||
from django.views.generic.base import RedirectView
|
||||
from rest_framework.documentation import include_docs_urls
|
||||
|
||||
from .views import auth_request
|
||||
from .views import IndexView, SearchView, DatabaseStatsView
|
||||
from .views import SettingsView, EditUserView, SetPasswordView
|
||||
from .views import CurrencySettingsView, CurrencyRefreshView
|
||||
@ -155,24 +156,28 @@ urlpatterns = [
|
||||
url(r'^search/', SearchView.as_view(), name='search'),
|
||||
url(r'^stats/', DatabaseStatsView.as_view(), name='stats'),
|
||||
|
||||
url(r'^auth/?', auth_request),
|
||||
|
||||
url(r'^api/', include(apipatterns)),
|
||||
url(r'^api-doc/', include_docs_urls(title='InvenTree API')),
|
||||
|
||||
url(r'^markdownx/', include('markdownx.urls')),
|
||||
]
|
||||
|
||||
# Static file access
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
# Server running in "DEBUG" mode?
|
||||
if settings.DEBUG:
|
||||
# Static file access
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
|
||||
# Media file access
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
# Media file access
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
# Debug toolbar access (if in DEBUG mode)
|
||||
if settings.DEBUG and 'debug_toolbar' in settings.INSTALLED_APPS:
|
||||
import debug_toolbar
|
||||
urlpatterns = [
|
||||
path('__debug/', include(debug_toolbar.urls)),
|
||||
] + urlpatterns
|
||||
# Debug toolbar access (only allowed in DEBUG mode)
|
||||
if 'debug_toolbar' in settings.INSTALLED_APPS:
|
||||
import debug_toolbar
|
||||
urlpatterns = [
|
||||
path('__debug/', include(debug_toolbar.urls)),
|
||||
] + urlpatterns
|
||||
|
||||
# Send any unknown URLs to the parts page
|
||||
urlpatterns += [url(r'^.*$', RedirectView.as_view(url='/index/', permanent=False), name='index')]
|
||||
|
@ -8,7 +8,7 @@ import re
|
||||
|
||||
import common.models
|
||||
|
||||
INVENTREE_SW_VERSION = "0.2.3 pre"
|
||||
INVENTREE_SW_VERSION = "0.2.4 pre"
|
||||
|
||||
"""
|
||||
Increment thi API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
@ -10,7 +10,7 @@ from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.template.loader import render_to_string
|
||||
from django.http import JsonResponse, HttpResponseRedirect
|
||||
from django.http import HttpResponse, JsonResponse, HttpResponseRedirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.conf import settings
|
||||
|
||||
@ -36,6 +36,19 @@ from .helpers import str2bool
|
||||
from rest_framework import views
|
||||
|
||||
|
||||
def auth_request(request):
|
||||
"""
|
||||
Simple 'auth' endpoint used to determine if the user is authenticated.
|
||||
Useful for (for example) redirecting authentication requests through
|
||||
django's permission framework.
|
||||
"""
|
||||
|
||||
if request.user.is_authenticated:
|
||||
return HttpResponse(status=200)
|
||||
else:
|
||||
return HttpResponse(status=403)
|
||||
|
||||
|
||||
class TreeSerializer(views.APIView):
|
||||
""" JSON View for serializing a Tree object.
|
||||
|
||||
|
@ -165,6 +165,19 @@ class BuildItemList(generics.ListCreateAPIView):
|
||||
|
||||
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):
|
||||
""" Override the queryset method,
|
||||
to allow filtering by stock_item.part
|
||||
|
@ -1289,10 +1289,23 @@ class BuildItem(models.Model):
|
||||
Return qualified URL for part thumbnail image
|
||||
"""
|
||||
|
||||
thumb_url = None
|
||||
|
||||
if self.stock_item and self.stock_item.part:
|
||||
return InvenTree.helpers.getMediaUrl(self.stock_item.part.image.thumbnail.url)
|
||||
elif self.bom_item and self.stock_item.sub_part:
|
||||
return InvenTree.helpers.getMediaUrl(self.bom_item.sub_part.image.thumbnail.url)
|
||||
try:
|
||||
# Try to extract the thumbnail
|
||||
thumb_url = self.stock_item.part.image.thumbnail.url
|
||||
except:
|
||||
pass
|
||||
|
||||
if thumb_url is None and self.bom_item and self.bom_item.sub_part:
|
||||
try:
|
||||
thumb_url = self.bom_item.sub_part.image.thumbnail.url
|
||||
except:
|
||||
pass
|
||||
|
||||
if thumb_url is not None:
|
||||
return InvenTree.helpers.getMediaUrl(thumb_url)
|
||||
else:
|
||||
return InvenTree.helpers.getBlankThumbnail()
|
||||
|
||||
|
@ -13,7 +13,8 @@ from rest_framework import serializers
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
|
||||
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
|
||||
|
||||
@ -99,22 +100,45 @@ class BuildItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
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_name = serializers.CharField(source='stock_item.part.full_name', read_only=True)
|
||||
part_thumb = serializers.CharField(source='getStockItemThumbnail', read_only=True)
|
||||
location = serializers.IntegerField(source='stock_item.location.pk', 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)
|
||||
location_detail = LocationSerializer(source='stock_item.location', read_only=True)
|
||||
|
||||
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:
|
||||
model = BuildItem
|
||||
fields = [
|
||||
'pk',
|
||||
'bom_part',
|
||||
'build',
|
||||
'build_detail',
|
||||
'install_into',
|
||||
'location',
|
||||
'location_detail',
|
||||
'part',
|
||||
'part_name',
|
||||
'part_thumb',
|
||||
'part_detail',
|
||||
'stock_item',
|
||||
'stock_item_detail',
|
||||
'quantity'
|
||||
|
@ -212,6 +212,21 @@ class InvenTreeSetting(models.Model):
|
||||
'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': {
|
||||
'name': _('Debug Mode'),
|
||||
'description': _('Generate reports in debug mode (HTML output)'),
|
||||
@ -733,7 +748,7 @@ class PriceBreak(models.Model):
|
||||
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.
|
||||
|
||||
- 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
|
||||
"""
|
||||
|
||||
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?
|
||||
if len(price_breaks) == 0:
|
||||
@ -763,7 +781,7 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None):
|
||||
currency = currency_code_default()
|
||||
|
||||
pb_min = None
|
||||
for pb in instance.price_breaks.all():
|
||||
for pb in price_breaks:
|
||||
# Store smallest price break
|
||||
if not pb_min:
|
||||
pb_min = pb
|
||||
|
@ -103,17 +103,11 @@ class ManufacturerPartList(generics.ListCreateAPIView):
|
||||
|
||||
# Do we wish to include extra detail?
|
||||
try:
|
||||
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
params = self.request.query_params
|
||||
|
||||
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))
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', None))
|
||||
kwargs['manufacturer_detail'] = str2bool(params.get('manufacturer_detail', None))
|
||||
kwargs['pretty'] = str2bool(params.get('pretty', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@ -252,22 +246,11 @@ class SupplierPartList(generics.ListCreateAPIView):
|
||||
|
||||
# Do we wish to include extra detail?
|
||||
try:
|
||||
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
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))
|
||||
params = self.request.query_params
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', None))
|
||||
kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', None))
|
||||
kwargs['manufacturer_detail'] = str2bool(self.params.get('manufacturer_detail', None))
|
||||
kwargs['pretty'] = str2bool(params.get('pretty', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
@ -52,3 +52,10 @@
|
||||
part: 2
|
||||
supplier: 2
|
||||
SKU: 'ZERGM312'
|
||||
|
||||
- model: company.supplierpart
|
||||
pk: 5
|
||||
fields:
|
||||
part: 4
|
||||
supplier: 2
|
||||
SKU: 'R_4K7_0603'
|
||||
|
@ -65,7 +65,7 @@ class CompanySimpleTest(TestCase):
|
||||
self.assertEqual(acme.supplied_part_count, 4)
|
||||
|
||||
self.assertTrue(appel.has_parts)
|
||||
self.assertEqual(appel.supplied_part_count, 3)
|
||||
self.assertEqual(appel.supplied_part_count, 4)
|
||||
|
||||
self.assertTrue(zerg.has_parts)
|
||||
self.assertEqual(zerg.supplied_part_count, 2)
|
||||
|
@ -129,9 +129,9 @@ cors:
|
||||
media_root: '/home/inventree/data/media'
|
||||
|
||||
# STATIC_ROOT is the local filesystem location for storing static files
|
||||
# By default, it is stored under /home/inventree
|
||||
# By default, it is stored under /home/inventree/data/static
|
||||
# Use environment variable INVENTREE_STATIC_ROOT
|
||||
static_root: '/home/inventree/static'
|
||||
static_root: '/home/inventree/data/static'
|
||||
|
||||
# Optional URL schemes to allow in URL fields
|
||||
# By default, only the following schemes are allowed: ['http', 'https', 'ftp', 'ftps']
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -22,9 +22,10 @@ from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from .models import PurchaseOrderAttachment
|
||||
from .serializers import POSerializer, POLineItemSerializer, POAttachmentSerializer
|
||||
|
||||
from .models import SalesOrder, SalesOrderLineItem
|
||||
from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation
|
||||
from .models import SalesOrderAttachment
|
||||
from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer
|
||||
from .serializers import SalesOrderAllocationSerializer
|
||||
|
||||
|
||||
class POList(generics.ListCreateAPIView):
|
||||
@ -422,17 +423,11 @@ class SOLineItemList(generics.ListCreateAPIView):
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
try:
|
||||
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
params = self.request.query_params
|
||||
|
||||
try:
|
||||
kwargs['order_detail'] = str2bool(self.request.query_params.get('order_detail', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
kwargs['allocations'] = str2bool(self.request.query_params.get('allocations', False))
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
|
||||
kwargs['order_detail'] = str2bool(params.get('order_detail', False))
|
||||
kwargs['allocations'] = str2bool(params.get('allocations', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@ -486,6 +481,70 @@ class SOLineItemDetail(generics.RetrieveUpdateAPIView):
|
||||
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):
|
||||
"""
|
||||
API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)
|
||||
@ -494,10 +553,6 @@ class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||
queryset = PurchaseOrderAttachment.objects.all()
|
||||
serializer_class = POAttachmentSerializer
|
||||
|
||||
filter_fields = [
|
||||
'order',
|
||||
]
|
||||
|
||||
|
||||
order_api_urls = [
|
||||
# API endpoints for purchase orders
|
||||
@ -512,14 +567,26 @@ order_api_urls = [
|
||||
url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'),
|
||||
|
||||
# API endpoints for sales ordesr
|
||||
url(r'^so/(?P<pk>\d+)/$', SODetail.as_view(), name='api-so-detail'),
|
||||
url(r'so/attachment/', include([
|
||||
url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'),
|
||||
url(r'^so/', 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'),
|
||||
])),
|
||||
|
||||
# List all sales orders
|
||||
url(r'^.*$', SOList.as_view(), name='api-so-list'),
|
||||
])),
|
||||
|
||||
url(r'^so/.*$', SOList.as_view(), name='api-so-list'),
|
||||
|
||||
# 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/$', SOLineItemList.as_view(), name='api-so-line-list'),
|
||||
url(r'^so-line/', include([
|
||||
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'),
|
||||
])),
|
||||
]
|
||||
|
@ -68,6 +68,7 @@
|
||||
order: 1
|
||||
part: 1
|
||||
quantity: 100
|
||||
destination: 5 # Desk/Drawer_1
|
||||
|
||||
# 250 x ACME0002 (M2x4 LPHS)
|
||||
# Partially received (50)
|
||||
@ -95,3 +96,10 @@
|
||||
part: 3
|
||||
quantity: 100
|
||||
|
||||
# 1 x R_4K7_0603
|
||||
- model: order.purchaseorderlineitem
|
||||
pk: 23
|
||||
fields:
|
||||
order: 1
|
||||
part: 5
|
||||
quantity: 1
|
||||
|
@ -86,12 +86,17 @@ class ShipSalesOrderForm(HelperForm):
|
||||
|
||||
class ReceivePurchaseOrderForm(HelperForm):
|
||||
|
||||
location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), required=True, label=_('Location'), help_text=_('Receive parts to this location'))
|
||||
location = TreeNodeChoiceField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
required=True,
|
||||
label=_("Destination"),
|
||||
help_text=_("Receive parts to this location"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
fields = [
|
||||
'location',
|
||||
"location",
|
||||
]
|
||||
|
||||
|
||||
@ -202,6 +207,7 @@ class EditPurchaseOrderLineItemForm(HelperForm):
|
||||
'quantity',
|
||||
'reference',
|
||||
'purchase_price',
|
||||
'destination',
|
||||
'notes',
|
||||
]
|
||||
|
||||
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
@ -20,6 +20,7 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from common.settings import currency_code_default
|
||||
|
||||
from markdownx.models import MarkdownxField
|
||||
from mptt.models import TreeForeignKey
|
||||
|
||||
from djmoney.models.fields import MoneyField
|
||||
|
||||
@ -672,6 +673,29 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
help_text=_('Unit purchase price'),
|
||||
)
|
||||
|
||||
destination = TreeForeignKey(
|
||||
'stock.StockLocation', on_delete=models.DO_NOTHING,
|
||||
verbose_name=_('Destination'),
|
||||
related_name='po_lines',
|
||||
blank=True, null=True,
|
||||
help_text=_('Where does the Purchaser want this item to be stored?')
|
||||
)
|
||||
|
||||
def get_destination(self):
|
||||
"""Show where the line item is or should be placed"""
|
||||
# NOTE: If a line item gets split when recieved, only an arbitrary
|
||||
# stock items location will be reported as the location for the
|
||||
# entire line.
|
||||
for stock in stock_models.StockItem.objects.filter(
|
||||
supplier_part=self.part, purchase_order=self.order
|
||||
):
|
||||
if stock.location:
|
||||
return stock.location
|
||||
if self.destination:
|
||||
return self.destination
|
||||
if self.part and self.part.part and self.part.part.default_location:
|
||||
return self.part.part.default_location
|
||||
|
||||
def remaining(self):
|
||||
""" Calculate the number of items remaining to be received """
|
||||
r = self.quantity - self.received
|
||||
|
@ -17,6 +17,8 @@ from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||
|
||||
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
|
||||
from part.serializers import PartBriefSerializer
|
||||
from stock.serializers import LocationBriefSerializer
|
||||
from stock.serializers import StockItemSerializer, LocationSerializer
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from .models import PurchaseOrderAttachment, SalesOrderAttachment
|
||||
@ -41,7 +43,7 @@ class POSerializer(InvenTreeModelSerializer):
|
||||
"""
|
||||
Add extra information to the queryset
|
||||
|
||||
- Number of liens in the PurchaseOrder
|
||||
- Number of lines in 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)
|
||||
|
||||
destination = LocationBriefSerializer(source='get_destination', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrderLineItem
|
||||
|
||||
@ -132,6 +136,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
'purchase_price',
|
||||
'purchase_price_currency',
|
||||
'purchase_price_string',
|
||||
'destination',
|
||||
]
|
||||
|
||||
|
||||
@ -232,11 +237,38 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
||||
This includes some fields from the related model objects.
|
||||
"""
|
||||
|
||||
location_path = serializers.CharField(source='get_location_path')
|
||||
location_id = serializers.IntegerField(source='get_location')
|
||||
serial = serializers.CharField(source='get_serial')
|
||||
po = serializers.CharField(source='get_po')
|
||||
quantity = serializers.FloatField()
|
||||
part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True)
|
||||
order = serializers.PrimaryKeyRelatedField(source='line.order', many=False, read_only=True)
|
||||
serial = serializers.CharField(source='get_serial', read_only=True)
|
||||
quantity = serializers.FloatField(read_only=True)
|
||||
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:
|
||||
model = SalesOrderAllocation
|
||||
@ -246,10 +278,14 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer):
|
||||
'line',
|
||||
'serial',
|
||||
'quantity',
|
||||
'location_id',
|
||||
'location_path',
|
||||
'po',
|
||||
'location',
|
||||
'location_detail',
|
||||
'item',
|
||||
'item_detail',
|
||||
'order',
|
||||
'order_detail',
|
||||
'part',
|
||||
'part_detail',
|
||||
]
|
||||
|
||||
|
||||
|
@ -117,6 +117,7 @@ $("#po-table").inventreeTable({
|
||||
part_detail: true,
|
||||
},
|
||||
url: "{% url 'api-po-line-list' %}",
|
||||
showFooter: true,
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
@ -137,6 +138,9 @@ $("#po-table").inventreeTable({
|
||||
return '-';
|
||||
}
|
||||
},
|
||||
footerFormatter: function() {
|
||||
return '{% trans "Total" %}'
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'part_detail.description',
|
||||
@ -172,7 +176,14 @@ $("#po-table").inventreeTable({
|
||||
{
|
||||
sortable: true,
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}'
|
||||
title: '{% trans "Quantity" %}',
|
||||
footerFormatter: function(data) {
|
||||
return data.map(function (row) {
|
||||
return +row['quantity']
|
||||
}).reduce(function (sum, i) {
|
||||
return sum + i
|
||||
}, 0)
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
@ -182,6 +193,25 @@ $("#po-table").inventreeTable({
|
||||
return row.purchase_price_string || row.purchase_price;
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
title: '{% trans "Total price" %}',
|
||||
formatter: function(value, row) {
|
||||
var total = row.purchase_price * row.quantity;
|
||||
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: row.purchase_price_currency});
|
||||
return formatter.format(total)
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
var total = data.map(function (row) {
|
||||
return +row['purchase_price']*row['quantity']
|
||||
}).reduce(function (sum, i) {
|
||||
return sum + i
|
||||
}, 0)
|
||||
var currency = (data.slice(-1)[0] && data.slice(-1)[0].purchase_price_currency) || 'USD';
|
||||
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: currency});
|
||||
return formatter.format(total)
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'received',
|
||||
@ -204,6 +234,10 @@ $("#po-table").inventreeTable({
|
||||
return (progressA < progressB) ? 1 : -1;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'destination.pathstring',
|
||||
title: '{% trans "Destination" %}',
|
||||
},
|
||||
{
|
||||
field: 'notes',
|
||||
title: '{% trans "Notes" %}',
|
||||
|
@ -22,6 +22,7 @@
|
||||
<th>{% trans "Received" %}</th>
|
||||
<th>{% trans "Receive" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th>{% trans "Destination" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for line in lines %}
|
||||
@ -53,6 +54,9 @@
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ line.get_destination }}
|
||||
</td>
|
||||
<td>
|
||||
<button class='btn btn-default btn-remove' onClick="removeOrderRowFromOrderWizard()" id='del_item_{{ line.id }}' title='{% trans "Remove line" %}' type='button'>
|
||||
<span row='line_row_{{ line.id }}' class='fas fa-times-circle icon-red'></span>
|
||||
|
@ -81,10 +81,10 @@ function showAllocationSubTable(index, row, element) {
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'location_id',
|
||||
field: 'location',
|
||||
title: 'Location',
|
||||
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,
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
showFooter: true,
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
@ -217,7 +218,10 @@ $("#so-lines-table").inventreeTable({
|
||||
} else {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
},
|
||||
footerFormatter: function() {
|
||||
return '{% trans "Total" %}'
|
||||
},
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
@ -228,6 +232,13 @@ $("#so-lines-table").inventreeTable({
|
||||
sortable: true,
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}',
|
||||
footerFormatter: function(data) {
|
||||
return data.map(function (row) {
|
||||
return +row['quantity']
|
||||
}).reduce(function (sum, i) {
|
||||
return sum + i
|
||||
}, 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
@ -237,6 +248,26 @@ $("#so-lines-table").inventreeTable({
|
||||
return row.sale_price_string || row.sale_price;
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
title: '{% trans "Total price" %}',
|
||||
formatter: function(value, row) {
|
||||
var total = row.sale_price * row.quantity;
|
||||
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: row.sale_price_currency});
|
||||
return formatter.format(total)
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
var total = data.map(function (row) {
|
||||
return +row['sale_price']*row['quantity']
|
||||
}).reduce(function (sum, i) {
|
||||
return sum + i
|
||||
}, 0)
|
||||
var currency = (data.slice(-1)[0] && data.slice(-1)[0].sale_price_currency) || 'USD';
|
||||
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: currency});
|
||||
return formatter.format(total)
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
field: 'allocated',
|
||||
{% if order.status == SalesOrderStatus.PENDING %}
|
||||
|
@ -87,7 +87,7 @@ class OrderTest(TestCase):
|
||||
order = PurchaseOrder.objects.get(pk=1)
|
||||
|
||||
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
|
||||
self.assertEqual(order.lines.count(), 3)
|
||||
self.assertEqual(order.lines.count(), 4)
|
||||
|
||||
sku = SupplierPart.objects.get(SKU='ACME-WIDGET')
|
||||
part = sku.part
|
||||
@ -105,11 +105,11 @@ class OrderTest(TestCase):
|
||||
order.add_line_item(sku, 100)
|
||||
|
||||
self.assertEqual(part.on_order, 100)
|
||||
self.assertEqual(order.lines.count(), 4)
|
||||
self.assertEqual(order.lines.count(), 5)
|
||||
|
||||
# Order the same part again (it should be merged)
|
||||
order.add_line_item(sku, 50)
|
||||
self.assertEqual(order.lines.count(), 4)
|
||||
self.assertEqual(order.lines.count(), 5)
|
||||
self.assertEqual(part.on_order, 150)
|
||||
|
||||
# Try to order a supplier part from the wrong supplier
|
||||
@ -163,7 +163,7 @@ class OrderTest(TestCase):
|
||||
loc = StockLocation.objects.get(id=1)
|
||||
|
||||
# There should be two lines against this order
|
||||
self.assertEqual(len(order.pending_line_items()), 3)
|
||||
self.assertEqual(len(order.pending_line_items()), 4)
|
||||
|
||||
# Should fail, as order is 'PENDING' not 'PLACED"
|
||||
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
|
||||
|
@ -14,7 +14,7 @@ from .models import BomItem
|
||||
from .models import PartParameterTemplate, PartParameter
|
||||
from .models import PartCategoryParameterTemplate
|
||||
from .models import PartTestTemplate
|
||||
from .models import PartSellPriceBreak
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
|
||||
from stock.models import StockLocation
|
||||
from company.models import SupplierPart
|
||||
@ -286,6 +286,14 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin):
|
||||
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(PartCategory, PartCategoryAdmin)
|
||||
admin.site.register(PartRelated, PartRelatedAdmin)
|
||||
@ -297,3 +305,4 @@ admin.site.register(PartParameter, ParameterAdmin)
|
||||
admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin)
|
||||
admin.site.register(PartTestTemplate, PartTestTemplateAdmin)
|
||||
admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin)
|
||||
admin.site.register(PartInternalPriceBreak, PartInternalPriceBreakAdmin)
|
||||
|
@ -25,7 +25,7 @@ from django.urls import reverse
|
||||
from .models import Part, PartCategory, BomItem
|
||||
from .models import PartParameter, PartParameterTemplate
|
||||
from .models import PartAttachment, PartTestTemplate
|
||||
from .models import PartSellPriceBreak
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
from .models import PartCategoryParameterTemplate
|
||||
|
||||
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):
|
||||
"""
|
||||
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'),
|
||||
])),
|
||||
|
||||
# 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
|
||||
url(r'^parameter/', include([
|
||||
url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-param-template-list'),
|
||||
|
51
InvenTree/part/fixtures/part_pricebreaks.yaml
Normal file
51
InvenTree/part/fixtures/part_pricebreaks.yaml
Normal 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
|
@ -20,7 +20,7 @@ from .models import BomItem
|
||||
from .models import PartParameterTemplate, PartParameter
|
||||
from .models import PartCategoryParameterTemplate
|
||||
from .models import PartTestTemplate
|
||||
from .models import PartSellPriceBreak
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
|
||||
|
||||
class PartModelChoiceField(forms.ModelChoiceField):
|
||||
@ -394,3 +394,19 @@ class EditPartSalePriceBreakForm(HelperForm):
|
||||
'quantity',
|
||||
'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',
|
||||
]
|
||||
|
30
InvenTree/part/migrations/0067_partinternalpricebreak.py
Normal file
30
InvenTree/part/migrations/0067_partinternalpricebreak.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
@ -1544,7 +1544,7 @@ class Part(MPTTModel):
|
||||
|
||||
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.
|
||||
Adds the minimum price for all components in the BOM.
|
||||
|
||||
@ -1561,7 +1561,7 @@ class Part(MPTTModel):
|
||||
print("Warning: Item contains itself in BOM")
|
||||
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:
|
||||
continue
|
||||
@ -1585,7 +1585,7 @@ class Part(MPTTModel):
|
||||
|
||||
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:
|
||||
|
||||
@ -1596,8 +1596,13 @@ class Part(MPTTModel):
|
||||
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
|
||||
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:
|
||||
return bom_price_range
|
||||
@ -1649,6 +1654,22 @@ class Part(MPTTModel):
|
||||
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
|
||||
def copy_bom_from(self, other, clear=True, **kwargs):
|
||||
"""
|
||||
@ -1983,6 +2004,21 @@ class PartSellPriceBreak(common.models.PriceBreak):
|
||||
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):
|
||||
""" A PartStar object creates a relationship between a User and a Part.
|
||||
|
||||
|
@ -17,7 +17,8 @@ from stock.models import StockItem
|
||||
|
||||
from .models import (BomItem, Part, PartAttachment, PartCategory,
|
||||
PartParameter, PartParameterTemplate, PartSellPriceBreak,
|
||||
PartStar, PartTestTemplate, PartCategoryParameterTemplate)
|
||||
PartStar, PartTestTemplate, PartCategoryParameterTemplate,
|
||||
PartInternalPriceBreak)
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
Serializer for the 'image' field of the Part model.
|
||||
|
@ -8,52 +8,43 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Part Stock Allocations" %}
|
||||
{% trans "Build Order Allocations" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
<table class='table table-striped table-condensed' id='build-table'>
|
||||
<tr>
|
||||
<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>
|
||||
|
||||
<table class='table table-striped table-condensed' id='build-order-table'></table>
|
||||
|
||||
{% 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.super }}
|
||||
|
||||
$("#build-table").inventreeTable({
|
||||
columns: [
|
||||
{
|
||||
title: '{% trans "Order" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
title: '{% trans "Stock Item" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
title: '{% trans "Quantity" %}',
|
||||
sortable: true,
|
||||
}
|
||||
]
|
||||
loadSalesOrderAllocationTable("#sales-order-table", {
|
||||
params: {
|
||||
part: {{ part.id }},
|
||||
}
|
||||
});
|
||||
|
||||
loadBuildOrderAllocationTable("#build-order-table", {
|
||||
params: {
|
||||
part: {{ part.id }},
|
||||
}
|
||||
});
|
||||
|
||||
{% endblock %}
|
||||
|
122
InvenTree/part/templates/part/internal_prices.html
Normal file
122
InvenTree/part/templates/part/internal_prices.html
Normal 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 %}
|
@ -2,6 +2,8 @@
|
||||
{% load static %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||
|
||||
<ul class='list-group'>
|
||||
<li class='list-group-item'>
|
||||
<a href='#' id='part-menu-toggle'>
|
||||
@ -94,7 +96,13 @@
|
||||
</a>
|
||||
</li>
|
||||
{% 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" %}'>
|
||||
<a href='{% url "part-sale-prices" part.id %}'>
|
||||
<span class='menu-tab-icon fas fa-dollar-sign sidebar-icon'></span>
|
||||
|
@ -14,8 +14,18 @@
|
||||
|
||||
{% block details %}
|
||||
{% default_currency as currency %}
|
||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||
|
||||
{% crispy form %}
|
||||
<form method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col-sm-9">{{ form|crispy }}</div>
|
||||
<div class="col-sm-3">
|
||||
<input type="submit" value="{% trans 'Calculate' %}" class="btn btn-primary btn-block">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<hr>
|
||||
|
||||
<div class="row"><div class="col col-md-6">
|
||||
<h4>{% trans "Pricing ranges" %}</h4>
|
||||
@ -77,6 +87,21 @@
|
||||
{% 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 %}
|
||||
<tr>
|
||||
<td><b>{% trans 'Sale Price' %}</b></td>
|
||||
@ -110,8 +135,8 @@
|
||||
|
||||
{% if price_history %}
|
||||
<hr>
|
||||
<h4>{% trans 'Stock Pricing' %}<i class="fas fa-info-circle" title="Shows the prices of stock for this part
|
||||
the part single price shown is the current price for that supplier part"></i></h4>
|
||||
<h4>{% trans 'Stock Pricing' %}<i class="fas fa-info-circle" title="Shows the purchase prices of stock for this part.
|
||||
The part single price is the current purchase price for that supplier part."></i></h4>
|
||||
{% if price_history|length > 1 %}
|
||||
<div style="max-width: 99%; min-height: 300px">
|
||||
<canvas id="StockPriceChart"></canvas>
|
||||
@ -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 %}
|
||||
],
|
||||
borderWidth: 1,
|
||||
type: 'line'
|
||||
type: 'line',
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
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 %}
|
||||
],
|
||||
borderWidth: 1,
|
||||
type: 'line'
|
||||
type: 'line',
|
||||
hidden: true,
|
||||
},
|
||||
{% endif %}
|
||||
{
|
||||
@ -187,18 +214,18 @@ the part single price shown is the current price for that supplier part"></i></h
|
||||
var bomdata = {
|
||||
labels: [{% for line in bom_parts %}'{{ line.name }}',{% endfor %}],
|
||||
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',
|
||||
data: [{% for line in bom_parts %}{{ line.max_price }},{% endfor %}],
|
||||
backgroundColor: bom_colors,
|
||||
},
|
||||
{% 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)
|
||||
|
@ -181,14 +181,27 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if part.trackable and part.getLatestSerialNumber %}
|
||||
<tr><td colspan="3"></td></tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-hashtag'></span></td>
|
||||
<td>{% trans "Latest Serial Number" %}</td>
|
||||
<td>{{ part.getLatestSerialNumber }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% block pre_content_panel %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
|
||||
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% block heading %}
|
||||
@ -202,7 +215,11 @@
|
||||
<!-- Specific part details go here... -->
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% block post_content_panel %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
@ -3,7 +3,10 @@
|
||||
{% load i18n inventree_extras %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
{% default_currency as currency %}
|
||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||
|
||||
<table class='table table-striped table-condensed table-price-two'>
|
||||
<tr>
|
||||
<td><b>{% trans 'Part' %}</b></td>
|
||||
@ -74,6 +77,22 @@
|
||||
</table>
|
||||
{% 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 %}
|
||||
<h4>{% trans 'Sale Price' %}</h4>
|
||||
<table class='table table-striped table-condensed table-price-two'>
|
||||
|
@ -210,9 +210,27 @@ def get_color_theme_css(username):
|
||||
|
||||
@register.filter
|
||||
def keyvalue(dict, key):
|
||||
"""
|
||||
access to key of supplied dict
|
||||
|
||||
usage:
|
||||
{% mydict|keyvalue:mykey %}
|
||||
"""
|
||||
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()
|
||||
def authorized_owners(group):
|
||||
""" Return authorized owners """
|
||||
|
@ -1,5 +1,6 @@
|
||||
from django.test import TestCase
|
||||
import django.core.exceptions as django_exceptions
|
||||
from decimal import Decimal
|
||||
|
||||
from .models import Part, BomItem
|
||||
|
||||
@ -11,11 +12,16 @@ class BomItemTest(TestCase):
|
||||
'part',
|
||||
'location',
|
||||
'bom',
|
||||
'company',
|
||||
'supplier_part',
|
||||
'part_pricebreaks',
|
||||
'price_breaks',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
self.bob = Part.objects.get(id=100)
|
||||
self.orphan = Part.objects.get(name='Orphan')
|
||||
self.r1 = Part.objects.get(name='R_2K2_0805')
|
||||
|
||||
def test_str(self):
|
||||
b = BomItem.objects.get(id=1)
|
||||
@ -111,3 +117,10 @@ class BomItemTest(TestCase):
|
||||
item.validate_hash()
|
||||
|
||||
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)))
|
||||
|
@ -51,6 +51,7 @@ class PartTest(TestCase):
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'part_pricebreaks'
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
@ -113,6 +114,22 @@ class PartTest(TestCase):
|
||||
|
||||
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):
|
||||
|
||||
|
@ -29,6 +29,12 @@ sale_price_break_urls = [
|
||||
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 = [
|
||||
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'),
|
||||
@ -65,6 +71,7 @@ part_detail_urls = [
|
||||
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'^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'^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'),
|
||||
@ -149,6 +156,9 @@ part_urls = [
|
||||
# Part price breaks
|
||||
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
|
||||
url(r'^test-template/', include([
|
||||
url(r'^new/', views.PartTestTemplateCreate.as_view(), name='part-test-template-create'),
|
||||
|
@ -37,7 +37,7 @@ from .models import PartCategoryParameterTemplate
|
||||
from .models import BomItem
|
||||
from .models import match_part_names
|
||||
from .models import PartTestTemplate
|
||||
from .models import PartSellPriceBreak
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from company.models import SupplierPart
|
||||
@ -1013,17 +1013,26 @@ class PartPricingView(PartDetail):
|
||||
ctx['price_history'] = ret
|
||||
|
||||
# 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 [True for a in bom_items if len(set(a['price'])) == 2]:
|
||||
ctx['bom_parts'] = [{
|
||||
'name': a['name'],
|
||||
'min_price': str((a['price'][0] * a['q']) / quantity),
|
||||
'max_price': str((a['price'][1] * a['q']) / quantity)} for a in bom_items]
|
||||
ctx['bom_pie_min'] = True
|
||||
else:
|
||||
ctx['bom_parts'] = [{
|
||||
'name': a['name'],
|
||||
'price': str((a['price'][0] * a['q']) / quantity)} for a in bom_items]
|
||||
if part.has_bom:
|
||||
ctx_bom_parts = []
|
||||
# iterate over all bom-items
|
||||
for item in part.bom_items.all():
|
||||
ctx_item = {'name': str(item.sub_part)}
|
||||
price, qty = item.sub_part.get_price_range(quantity), item.quantity
|
||||
|
||||
price_min, price_max = 0, 0
|
||||
if price: # check if price available
|
||||
price_min = str((price[0] * qty) / quantity)
|
||||
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
|
||||
|
||||
@ -2272,7 +2281,8 @@ class PartPricing(AjaxView):
|
||||
# BOM pricing information
|
||||
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:
|
||||
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_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_price = part.get_price(quantity)
|
||||
if part_price is not None:
|
||||
@ -2961,3 +2977,29 @@ class PartSalePriceBreakDelete(AjaxDeleteView):
|
||||
model = PartSellPriceBreak
|
||||
ajax_form_title = _("Delete Price Break")
|
||||
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'
|
||||
|
@ -161,6 +161,13 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False)
|
||||
|
||||
purchase_price = serializers.SerializerMethodField()
|
||||
|
||||
def get_purchase_price(self, obj):
|
||||
""" Return purchase_price (Money field) as string (includes currency) """
|
||||
|
||||
return str(obj.purchase_price) if obj.purchase_price else '-'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
@ -215,6 +222,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
||||
'tracking_items',
|
||||
'uid',
|
||||
'updated',
|
||||
'purchase_price',
|
||||
]
|
||||
|
||||
""" These fields are read-only in this context.
|
||||
|
@ -34,6 +34,9 @@
|
||||
{% 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_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>
|
||||
</table>
|
||||
|
||||
|
@ -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={}) {
|
||||
/*
|
||||
* Load the "allocation table" for a particular build output.
|
||||
@ -347,6 +429,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
|
||||
var params = {
|
||||
build: buildId,
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
}
|
||||
|
||||
if (output) {
|
||||
@ -466,8 +550,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
title: '{% trans "Part" %}',
|
||||
formatter: function(value, row) {
|
||||
|
||||
var html = imageHoverIcon(row.part_thumb);
|
||||
html += renderLink(row.part_name, `/part/${value}/`);
|
||||
var html = imageHoverIcon(row.part_detail.thumbnail);
|
||||
html += renderLink(row.part_detail.full_name, `/part/${value}/`);
|
||||
return html;
|
||||
}
|
||||
},
|
||||
|
@ -775,7 +775,9 @@ function handleModalForm(url, options) {
|
||||
}
|
||||
// Form was returned, invalid!
|
||||
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');
|
||||
warningDiv.css('display', 'block');
|
||||
}
|
||||
@ -791,13 +793,16 @@ function handleModalForm(url, options) {
|
||||
attachSecondaries(modal, options.secondary);
|
||||
}
|
||||
|
||||
// Set modal title with response
|
||||
if (response.title) {
|
||||
modalSetTitle(modal, response.title);
|
||||
}
|
||||
|
||||
// Clean custom action buttons
|
||||
$(modal).find('#modal-footer-buttons').html('');
|
||||
|
||||
// Add custom action buttons with response
|
||||
if (response.buttons) {
|
||||
// Clean custom action buttons
|
||||
$(modal).find('#modal-footer-buttons').html('');
|
||||
attachButtons(modal, response.buttons);
|
||||
}
|
||||
}
|
||||
@ -846,6 +851,7 @@ function launchModalForm(url, options = {}) {
|
||||
* secondary - List of secondary modals to attach
|
||||
* callback - List of callback functions to attach to inputs
|
||||
* 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';
|
||||
@ -905,6 +911,11 @@ function launchModalForm(url, options = {}) {
|
||||
attachButtons(modal, options.buttons);
|
||||
}
|
||||
|
||||
// Add custom buttons from response
|
||||
if (response.buttons) {
|
||||
attachButtons(modal, response.buttons);
|
||||
}
|
||||
|
||||
} else {
|
||||
$(modal).modal('hide');
|
||||
showAlertDialog('{% trans "Invalid server response" %}', '{% trans "JSON response missing form data" %}');
|
||||
|
@ -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" %}',
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
@ -159,7 +159,7 @@ function loadStockTestResultsTable(table, options) {
|
||||
|
||||
// Set "parent" for each existing row
|
||||
tableData.forEach(function(item, idx) {
|
||||
tableData[idx].parent = options.stock_item;
|
||||
tableData[idx].parent = parent_node;
|
||||
});
|
||||
|
||||
// Once the test template data are loaded, query for test results
|
||||
@ -660,6 +660,11 @@ function loadStockTable(table, options) {
|
||||
title: '{% trans "Last Updated" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'purchase_price',
|
||||
title: '{% trans "Purchase Price" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'packaging',
|
||||
title: '{% trans "Packaging" %}',
|
||||
|
@ -135,7 +135,7 @@ $.fn.inventreeTable = function(options) {
|
||||
|
||||
// Pagingation options (can be server-side or client-side as specified by the caller)
|
||||
options.pagination = true;
|
||||
options.paginationVAlign = 'both';
|
||||
options.paginationVAlign = options.paginationVAlign || 'both';
|
||||
options.pageSize = inventreeLoad(varName, 25);
|
||||
options.pageList = [25, 50, 100, 250, 'all'];
|
||||
options.totalField = 'count';
|
||||
|
@ -77,6 +77,7 @@ class RuleSet(models.Model):
|
||||
'part_bomitem',
|
||||
'part_partattachment',
|
||||
'part_partsellpricebreak',
|
||||
'part_partinternalpricebreak',
|
||||
'part_parttesttemplate',
|
||||
'part_partparametertemplate',
|
||||
'part_partparameter',
|
||||
|
@ -7,6 +7,8 @@ ARG branch="master"
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
# InvenTree key settings
|
||||
|
||||
# The INVENTREE_HOME directory is where the InvenTree source repository will be located
|
||||
ENV INVENTREE_HOME="/home/inventree"
|
||||
|
||||
# GitHub settings
|
||||
@ -17,10 +19,9 @@ ENV INVENTREE_LOG_LEVEL="INFO"
|
||||
ENV INVENTREE_DOCKER="true"
|
||||
|
||||
# InvenTree paths
|
||||
ENV INVENTREE_SRC_DIR="${INVENTREE_HOME}/src"
|
||||
ENV INVENTREE_MNG_DIR="${INVENTREE_SRC_DIR}/InvenTree"
|
||||
ENV INVENTREE_MNG_DIR="${INVENTREE_HOME}/InvenTree"
|
||||
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_CONFIG_FILE="${INVENTREE_DATA_DIR}/config.yaml"
|
||||
@ -44,8 +45,6 @@ RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup
|
||||
|
||||
WORKDIR ${INVENTREE_HOME}
|
||||
|
||||
RUN mkdir -p ${INVENTREE_STATIC_ROOT}
|
||||
|
||||
# Install required system packages
|
||||
RUN apk add --no-cache git make bash \
|
||||
gcc libgcc g++ libstdc++ \
|
||||
@ -78,37 +77,40 @@ RUN pip install --no-cache-dir -U gunicorn
|
||||
FROM base as production
|
||||
# Clone source code
|
||||
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
|
||||
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.conf.py ${INVENTREE_HOME}/gunicorn.conf.py
|
||||
|
||||
# Copy startup scripts
|
||||
COPY start_prod_server.sh ${INVENTREE_SRC_DIR}/start_prod_server.sh
|
||||
COPY start_worker.sh ${INVENTREE_SRC_DIR}/start_worker.sh
|
||||
COPY start_prod_server.sh ${INVENTREE_HOME}/start_prod_server.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_SRC_DIR}/start_worker.sh
|
||||
RUN chmod 755 ${INVENTREE_HOME}/start_prod_server.sh
|
||||
RUN chmod 755 ${INVENTREE_HOME}/start_prod_worker.sh
|
||||
|
||||
# exec commands should be executed from the "src" directory
|
||||
WORKDIR ${INVENTREE_SRC_DIR}
|
||||
WORKDIR ${INVENTREE_HOME}
|
||||
|
||||
# Let us begin
|
||||
CMD ["bash", "./start_prod_server.sh"]
|
||||
|
||||
FROM base as dev
|
||||
|
||||
# The development image requires the source code to be mounted to /home/inventree/src/
|
||||
# So from here, we don't actually "do" anything
|
||||
# The development image requires the source code to be mounted to /home/inventree/
|
||||
# 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
|
||||
COPY start_dev_worker.sh ${INVENTREE_HOME}/start_dev_worker.sh
|
||||
RUN chmod 755 ${INVENTREE_HOME}/start_dev_server.sh
|
||||
RUN chmod 755 ${INVENTREE_HOME}/start_dev_worker.sh
|
||||
# Override default path settings
|
||||
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DEV_DIR}/static"
|
||||
ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DEV_DIR}/media"
|
||||
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"]
|
||||
|
@ -1,7 +1,9 @@
|
||||
INVENTREE_DB_ENGINE=sqlite3
|
||||
INVENTREE_DB_NAME=/home/inventree/src/inventree_docker_dev.sqlite3
|
||||
INVENTREE_MEDIA_ROOT=/home/inventree/src/inventree_media
|
||||
INVENTREE_STATIC_ROOT=/home/inventree/src/inventree_static
|
||||
INVENTREE_CONFIG_FILE=/home/inventree/src/config.yaml
|
||||
INVENTREE_SECRET_KEY_FILE=/home/inventree/src/secret_key.txt
|
||||
INVENTREE_DEBUG=true
|
||||
INVENTREE_DB_NAME=/home/inventree/dev/inventree_db.sqlite3
|
||||
INVENTREE_MEDIA_ROOT=/home/inventree/dev/media
|
||||
INVENTREE_STATIC_ROOT=/home/inventree/dev/static
|
||||
INVENTREE_CONFIG_FILE=/home/inventree/dev/config.yaml
|
||||
INVENTREE_SECRET_KEY_FILE=/home/inventree/dev/secret_key.txt
|
||||
INVENTREE_DEBUG=true
|
||||
INVENTREE_WEB_ADDR=0.0.0.0
|
||||
INVENTREE_WEB_PORT=8000
|
@ -13,8 +13,8 @@ version: "3.8"
|
||||
services:
|
||||
# InvenTree web server services
|
||||
# Uses gunicorn as the web server
|
||||
inventree-server:
|
||||
container_name: inventree-server
|
||||
inventree-dev-server:
|
||||
container_name: inventree-dev-server
|
||||
build:
|
||||
context: .
|
||||
target: dev
|
||||
@ -22,7 +22,7 @@ services:
|
||||
- 8000:8000
|
||||
volumes:
|
||||
# Ensure you specify the location of the 'src' directory at the end of this file
|
||||
- src:/home/inventree/src
|
||||
- src:/home/inventree
|
||||
env_file:
|
||||
# Environment variables required for the dev server are configured in dev-config.env
|
||||
- dev-config.env
|
||||
@ -30,24 +30,24 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
# Background worker process handles long-running or periodic tasks
|
||||
inventree-worker:
|
||||
container_name: inventree-worker
|
||||
inventree-dev-worker:
|
||||
container_name: inventree-dev-worker
|
||||
build:
|
||||
context: .
|
||||
target: dev
|
||||
entrypoint: /home/inventree/start_dev_worker.sh
|
||||
entrypoint: /home/inventree/docker/start_dev_worker.sh
|
||||
depends_on:
|
||||
- inventree-server
|
||||
- inventree-dev-server
|
||||
volumes:
|
||||
# Ensure you specify the location of the 'src' directory at the end of this file
|
||||
- src:/home/inventree/src
|
||||
- src:/home/inventree
|
||||
env_file:
|
||||
# Environment variables required for the dev server are configured in dev-config.env
|
||||
- dev-config.env
|
||||
restart: unless-stopped
|
||||
|
||||
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)
|
||||
src:
|
||||
driver: local
|
||||
@ -55,5 +55,5 @@ volumes:
|
||||
type: none
|
||||
o: bind
|
||||
# This directory specified where InvenTree source code is stored "outside" the docker containers
|
||||
# Note: This directory must conatin the file *manage.py*
|
||||
device: /path/to/inventree/src
|
||||
# By default, this directory is one level above the "docker" directory
|
||||
device: ../
|
||||
|
@ -30,6 +30,7 @@ services:
|
||||
- POSTGRES_USER=pguser
|
||||
- POSTGRES_PASSWORD=pgpassword
|
||||
volumes:
|
||||
# Map 'data' volume such that postgres database is stored externally
|
||||
- data:/var/lib/postgresql/data/
|
||||
restart: unless-stopped
|
||||
|
||||
@ -43,8 +44,8 @@ services:
|
||||
depends_on:
|
||||
- inventree-db
|
||||
volumes:
|
||||
# Data volume must map to /home/inventree/data
|
||||
- data:/home/inventree/data
|
||||
- static:/home/inventree/static
|
||||
environment:
|
||||
# Default environment variables are configured to match the 'db' container
|
||||
# Note: If you change the database image, these will need to be adjusted
|
||||
@ -61,13 +62,13 @@ services:
|
||||
inventree-worker:
|
||||
container_name: inventree-worker
|
||||
image: inventree/inventree:latest
|
||||
entrypoint: ./start_worker.sh
|
||||
entrypoint: ./start_prod_worker.sh
|
||||
depends_on:
|
||||
- inventree-db
|
||||
- inventree-server
|
||||
volumes:
|
||||
# Data volume must map to /home/inventree/data
|
||||
- data:/home/inventree/data
|
||||
- static:/home/inventree/static
|
||||
environment:
|
||||
# Default environment variables are configured to match the 'db' container
|
||||
# Note: If you change the database image, these will need to be adjusted
|
||||
@ -81,7 +82,8 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
# 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
|
||||
# NOTE: You will need to provide a working nginx.conf file!
|
||||
inventree-proxy:
|
||||
@ -93,11 +95,11 @@ services:
|
||||
# Change "1337" to the port that you want InvenTree web server to be available on
|
||||
- 1337:80
|
||||
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
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
# Static data volume is mounted to /var/www/static
|
||||
- static:/var/www/static:ro
|
||||
# nginx proxy needs access to static and media files
|
||||
- data:/var/www
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
@ -110,6 +112,4 @@ volumes:
|
||||
o: bind
|
||||
# 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
|
||||
device: /path/to/data
|
||||
# Static files, shared between containers
|
||||
static:
|
||||
device: /path/to/data
|
@ -1,3 +1,4 @@
|
||||
|
||||
server {
|
||||
|
||||
# Listen for connection on (internal) port 80
|
||||
@ -34,4 +35,23 @@ server {
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
@ -16,21 +16,22 @@ if test -f "$INVENTREE_CONFIG_FILE"; then
|
||||
echo "$INVENTREE_CONFIG_FILE exists - skipping"
|
||||
else
|
||||
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
|
||||
|
||||
# Setup a virtual environment
|
||||
python3 -m venv inventree-docker-dev
|
||||
# Setup a virtual environment (within the "dev" directory)
|
||||
python3 -m venv ./dev/env
|
||||
|
||||
source inventree-docker-dev/bin/activate
|
||||
# Activate the virtual environment
|
||||
source ./dev/env/bin/activate
|
||||
|
||||
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..."
|
||||
|
||||
# Wait for the database to be ready
|
||||
cd $INVENTREE_MNG_DIR
|
||||
cd ${INVENTREE_HOME}/InvenTree
|
||||
python manage.py wait_for_db
|
||||
|
||||
sleep 10
|
||||
@ -45,4 +46,4 @@ python manage.py migrate --run-syncdb || exit 1
|
||||
python manage.py clearsessions || exit 1
|
||||
|
||||
# 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}
|
||||
|
@ -2,15 +2,15 @@
|
||||
|
||||
echo "Starting InvenTree worker..."
|
||||
|
||||
cd $INVENTREE_SRC_DIR
|
||||
cd $INVENTREE_HOME
|
||||
|
||||
# Activate virtual environment
|
||||
source inventree-docker-dev/bin/activate
|
||||
source ./dev/env/bin/activate
|
||||
|
||||
sleep 5
|
||||
|
||||
# Wait for the database to be ready
|
||||
cd $INVENTREE_MNG_DIR
|
||||
cd InvenTree
|
||||
python manage.py wait_for_db
|
||||
|
||||
sleep 10
|
||||
|
@ -16,7 +16,7 @@ if test -f "$INVENTREE_CONFIG_FILE"; then
|
||||
echo "$INVENTREE_CONFIG_FILE exists - skipping"
|
||||
else
|
||||
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
|
||||
|
||||
echo "Starting InvenTree server..."
|
||||
|
@ -1,7 +1,7 @@
|
||||
invoke>=1.4.0 # Invoke build tool
|
||||
wheel>=0.34.2 # Wheel
|
||||
Django==3.2.1 # Django package
|
||||
pillow==8.1.1 # Image manipulation
|
||||
Django==3.2.4 # Django package
|
||||
pillow==8.2.0 # Image manipulation
|
||||
djangorestframework==3.12.4 # DRF framework
|
||||
django-cors-headers==3.2.0 # CORS headers extension for DRF
|
||||
django-filter==2.4.0 # Extended filtering options
|
||||
|
16
tasks.py
16
tasks.py
@ -129,6 +129,14 @@ def wait(c):
|
||||
|
||||
manage(c, "wait_for_db")
|
||||
|
||||
@task
|
||||
def rebuild(c):
|
||||
"""
|
||||
Rebuild database models with MPTT structures
|
||||
"""
|
||||
|
||||
manage(c, "rebuild_models")
|
||||
|
||||
@task
|
||||
def migrate(c):
|
||||
"""
|
||||
@ -282,7 +290,7 @@ def export_records(c, filename='data.json'):
|
||||
|
||||
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
|
||||
manage(c, cmd, pty=True)
|
||||
@ -311,7 +319,7 @@ def export_records(c, filename='data.json'):
|
||||
print("Data export completed")
|
||||
|
||||
|
||||
@task(help={'filename': 'Input filename'})
|
||||
@task(help={'filename': 'Input filename'}, post=[rebuild])
|
||||
def import_records(c, filename='data.json'):
|
||||
"""
|
||||
Import database records from a file
|
||||
@ -348,13 +356,13 @@ def import_records(c, filename='data.json'):
|
||||
with open(tmpfile, "w") as f_out:
|
||||
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)
|
||||
|
||||
print("Data import completed")
|
||||
|
||||
@task
|
||||
@task(post=[rebuild])
|
||||
def import_fixtures(c):
|
||||
"""
|
||||
Import fixture data into the database.
|
||||
|
Loading…
Reference in New Issue
Block a user