mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 21:15:41 +00:00
Merge remote-tracking branch 'inventree/master' into order-parts-wizard
# Conflicts: # InvenTree/templates/js/translated/model_renderers.js
This commit is contained in:
@ -262,6 +262,15 @@ class CategoryTree(generics.ListAPIView):
|
||||
ordering = ['level', 'name']
|
||||
|
||||
|
||||
class PartSalePriceDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
Detail endpoint for PartSellPriceBreak model
|
||||
"""
|
||||
|
||||
queryset = PartSellPriceBreak.objects.all()
|
||||
serializer_class = part_serializers.PartSalePriceSerializer
|
||||
|
||||
|
||||
class PartSalePriceList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for list view of PartSalePriceBreak model
|
||||
@ -279,6 +288,15 @@ class PartSalePriceList(generics.ListCreateAPIView):
|
||||
]
|
||||
|
||||
|
||||
class PartInternalPriceDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
Detail endpoint for PartInternalPriceBreak model
|
||||
"""
|
||||
|
||||
queryset = PartInternalPriceBreak.objects.all()
|
||||
serializer_class = part_serializers.PartInternalPriceSerializer
|
||||
|
||||
|
||||
class PartInternalPriceList(generics.ListCreateAPIView):
|
||||
"""
|
||||
API endpoint for list view of PartInternalPriceBreak model
|
||||
@ -1175,6 +1193,18 @@ class PartList(generics.ListCreateAPIView):
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Filter by 'variant_of'
|
||||
# Note that this is subtly different from 'ancestor' filter (above)
|
||||
variant_of = params.get('variant_of', None)
|
||||
|
||||
if variant_of is not None:
|
||||
try:
|
||||
template = Part.objects.get(pk=variant_of)
|
||||
variants = template.get_children()
|
||||
queryset = queryset.filter(pk__in=[v.pk for v in variants])
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Filter only parts which are in the "BOM" for a given part
|
||||
in_bom_for = params.get('in_bom_for', None)
|
||||
|
||||
@ -1339,10 +1369,6 @@ class PartList(generics.ListCreateAPIView):
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
|
||||
filter_fields = [
|
||||
'variant_of',
|
||||
]
|
||||
|
||||
ordering_fields = [
|
||||
'name',
|
||||
'creation_date',
|
||||
@ -1602,9 +1628,10 @@ class BomList(generics.ListCreateAPIView):
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = BomItem.objects.all()
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = self.get_serializer_class().setup_eager_loading(queryset)
|
||||
queryset = self.get_serializer_class().annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
@ -1818,6 +1845,15 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
queryset = BomItem.objects.all()
|
||||
serializer_class = part_serializers.BomItemSerializer
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = self.get_serializer_class().setup_eager_loading(queryset)
|
||||
queryset = self.get_serializer_class().annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class BomItemValidate(generics.UpdateAPIView):
|
||||
""" API endpoint for validating a BomItem """
|
||||
@ -1902,11 +1938,13 @@ part_api_urls = [
|
||||
|
||||
# Base URL for part sale pricing
|
||||
url(r'^sale-price/', include([
|
||||
url(r'^(?P<pk>\d+)/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'),
|
||||
url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
|
||||
])),
|
||||
|
||||
# Base URL for part internal pricing
|
||||
url(r'^internal-price/', include([
|
||||
url(r'^(?P<pk>\d+)/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'),
|
||||
url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
|
||||
])),
|
||||
|
||||
|
@ -7,6 +7,7 @@
|
||||
part: 100
|
||||
sub_part: 1
|
||||
quantity: 10
|
||||
allow_variants: True
|
||||
|
||||
# 40 x R_2K2_0805
|
||||
- model: part.bomitem
|
||||
|
@ -177,6 +177,7 @@
|
||||
fields:
|
||||
name: 'Green chair variant'
|
||||
variant_of: 10003
|
||||
is_template: true
|
||||
category: 7
|
||||
trackable: true
|
||||
tree_id: 1
|
||||
|
@ -777,7 +777,8 @@ class Part(MPTTModel):
|
||||
# User can decide whether duplicate IPN (Internal Part Number) values are allowed
|
||||
allow_duplicate_ipn = common.models.InvenTreeSetting.get_setting('PART_ALLOW_DUPLICATE_IPN')
|
||||
|
||||
if self.IPN is not None and not allow_duplicate_ipn:
|
||||
# Raise an error if an IPN is set, and it is a duplicate
|
||||
if self.IPN and not allow_duplicate_ipn:
|
||||
parts = Part.objects.filter(IPN__iexact=self.IPN)
|
||||
parts = parts.exclude(pk=self.pk)
|
||||
|
||||
@ -798,6 +799,10 @@ class Part(MPTTModel):
|
||||
|
||||
super().clean()
|
||||
|
||||
# Strip IPN field
|
||||
if type(self.IPN) is str:
|
||||
self.IPN = self.IPN.strip()
|
||||
|
||||
if self.trackable:
|
||||
for part in self.get_used_in().all():
|
||||
|
||||
@ -1313,19 +1318,31 @@ class Part(MPTTModel):
|
||||
|
||||
return quantity
|
||||
|
||||
def build_order_allocations(self):
|
||||
def build_order_allocations(self, **kwargs):
|
||||
"""
|
||||
Return all 'BuildItem' objects which allocate this part to Build objects
|
||||
"""
|
||||
|
||||
return BuildModels.BuildItem.objects.filter(stock_item__part__id=self.id)
|
||||
include_variants = kwargs.get('include_variants', True)
|
||||
|
||||
def build_order_allocation_count(self):
|
||||
queryset = BuildModels.BuildItem.objects.all()
|
||||
|
||||
if include_variants:
|
||||
variants = self.get_descendants(include_self=True)
|
||||
queryset = queryset.filter(
|
||||
stock_item__part__in=variants,
|
||||
)
|
||||
else:
|
||||
queryset = queryset.filter(stock_item__part=self)
|
||||
|
||||
return queryset
|
||||
|
||||
def build_order_allocation_count(self, **kwargs):
|
||||
"""
|
||||
Return the total amount of this part allocated to build orders
|
||||
"""
|
||||
|
||||
query = self.build_order_allocations().aggregate(
|
||||
query = self.build_order_allocations(**kwargs).aggregate(
|
||||
total=Coalesce(
|
||||
Sum(
|
||||
'quantity',
|
||||
@ -1343,7 +1360,19 @@ class Part(MPTTModel):
|
||||
Return all sales-order-allocation objects which allocate this part to a SalesOrder
|
||||
"""
|
||||
|
||||
queryset = OrderModels.SalesOrderAllocation.objects.filter(item__part__id=self.id)
|
||||
include_variants = kwargs.get('include_variants', True)
|
||||
|
||||
queryset = OrderModels.SalesOrderAllocation.objects.all()
|
||||
|
||||
if include_variants:
|
||||
# Include allocations for all variants
|
||||
variants = self.get_descendants(include_self=True)
|
||||
queryset = queryset.filter(
|
||||
item__part__in=variants,
|
||||
)
|
||||
else:
|
||||
# Only look at this part
|
||||
queryset = queryset.filter(item__part=self)
|
||||
|
||||
# Default behaviour is to only return *pending* allocations
|
||||
pending = kwargs.get('pending', True)
|
||||
@ -1381,7 +1410,7 @@ class Part(MPTTModel):
|
||||
|
||||
return query['total']
|
||||
|
||||
def allocation_count(self):
|
||||
def allocation_count(self, **kwargs):
|
||||
"""
|
||||
Return the total quantity of stock allocated for this part,
|
||||
against both build orders and sales orders.
|
||||
@ -1389,8 +1418,8 @@ class Part(MPTTModel):
|
||||
|
||||
return sum(
|
||||
[
|
||||
self.build_order_allocation_count(),
|
||||
self.sales_order_allocation_count(),
|
||||
self.build_order_allocation_count(**kwargs),
|
||||
self.sales_order_allocation_count(**kwargs),
|
||||
],
|
||||
)
|
||||
|
||||
@ -2703,7 +2732,21 @@ class BomItem(models.Model, DataImportMixin):
|
||||
for sub in self.substitutes.all():
|
||||
parts.add(sub.part)
|
||||
|
||||
return parts
|
||||
valid_parts = []
|
||||
|
||||
for p in parts:
|
||||
|
||||
# Inactive parts cannot be 'auto allocated'
|
||||
if not p.active:
|
||||
continue
|
||||
|
||||
# Trackable parts cannot be 'auto allocated'
|
||||
if p.trackable:
|
||||
continue
|
||||
|
||||
valid_parts.append(p)
|
||||
|
||||
return valid_parts
|
||||
|
||||
def is_stock_item_valid(self, stock_item):
|
||||
"""
|
||||
@ -2882,23 +2925,6 @@ class BomItem(models.Model, DataImportMixin):
|
||||
child=self.sub_part.full_name,
|
||||
n=decimal2string(self.quantity))
|
||||
|
||||
def available_stock(self):
|
||||
"""
|
||||
Return the available stock items for the referenced sub_part
|
||||
"""
|
||||
|
||||
query = self.sub_part.stock_items.all()
|
||||
|
||||
query = query.prefetch_related([
|
||||
'sub_part__stock_items',
|
||||
])
|
||||
|
||||
query = query.filter(StockModels.StockItem.IN_STOCK_FILTER).aggregate(
|
||||
available=Coalesce(Sum('quantity'), 0)
|
||||
)
|
||||
|
||||
return query['available']
|
||||
|
||||
def get_overage_quantity(self, quantity):
|
||||
""" Calculate overage quantity
|
||||
"""
|
||||
|
@ -7,7 +7,9 @@ from decimal import Decimal
|
||||
|
||||
from django.urls import reverse_lazy
|
||||
from django.db import models, transaction
|
||||
from django.db.models import ExpressionWrapper, F, Q
|
||||
from django.db.models import ExpressionWrapper, F, Q, Func
|
||||
from django.db.models import Subquery, OuterRef, FloatField
|
||||
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
@ -15,6 +17,8 @@ from rest_framework import serializers
|
||||
from sql_util.utils import SubqueryCount, SubquerySum
|
||||
from djmoney.contrib.django_rest_framework import MoneyField
|
||||
|
||||
from common.settings import currency_code_default, currency_code_mappings
|
||||
|
||||
from InvenTree.serializers import (DataFileUploadSerializer,
|
||||
DataFileExtractSerializer,
|
||||
InvenTreeAttachmentSerializerField,
|
||||
@ -146,6 +150,13 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
|
||||
allow_null=True
|
||||
)
|
||||
|
||||
price_currency = serializers.ChoiceField(
|
||||
choices=currency_code_mappings(),
|
||||
default=currency_code_default,
|
||||
label=_('Currency'),
|
||||
help_text=_('Purchase currency of this stock item'),
|
||||
)
|
||||
|
||||
price_string = serializers.CharField(source='price', read_only=True)
|
||||
|
||||
class Meta:
|
||||
@ -155,6 +166,7 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
|
||||
'part',
|
||||
'quantity',
|
||||
'price',
|
||||
'price_currency',
|
||||
'price_string',
|
||||
]
|
||||
|
||||
@ -170,6 +182,13 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
|
||||
allow_null=True
|
||||
)
|
||||
|
||||
price_currency = serializers.ChoiceField(
|
||||
choices=currency_code_mappings(),
|
||||
default=currency_code_default,
|
||||
label=_('Currency'),
|
||||
help_text=_('Purchase currency of this stock item'),
|
||||
)
|
||||
|
||||
price_string = serializers.CharField(source='price', read_only=True)
|
||||
|
||||
class Meta:
|
||||
@ -179,6 +198,7 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
|
||||
'part',
|
||||
'quantity',
|
||||
'price',
|
||||
'price_currency',
|
||||
'price_string',
|
||||
]
|
||||
|
||||
@ -308,9 +328,6 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
to reduce database trips.
|
||||
"""
|
||||
|
||||
# TODO: Update the "in_stock" annotation to include stock for variants of the part
|
||||
# Ref: https://github.com/inventree/InvenTree/issues/2240
|
||||
|
||||
# Annotate with the total 'in stock' quantity
|
||||
queryset = queryset.annotate(
|
||||
in_stock=Coalesce(
|
||||
@ -325,6 +342,24 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
stock_item_count=SubqueryCount('stock_items')
|
||||
)
|
||||
|
||||
# Annotate with the total variant stock quantity
|
||||
variant_query = StockItem.objects.filter(
|
||||
part__tree_id=OuterRef('tree_id'),
|
||||
part__lft__gt=OuterRef('lft'),
|
||||
part__rght__lt=OuterRef('rght'),
|
||||
).filter(StockItem.IN_STOCK_FILTER)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
variant_stock=Coalesce(
|
||||
Subquery(
|
||||
variant_query.annotate(
|
||||
total=Func(F('quantity'), function='SUM', output_field=FloatField())
|
||||
).values('total')),
|
||||
0,
|
||||
output_field=FloatField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Filter to limit builds to "active"
|
||||
build_filter = Q(
|
||||
status__in=BuildStatus.ACTIVE_CODES
|
||||
@ -429,6 +464,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
unallocated_stock = serializers.FloatField(read_only=True)
|
||||
building = serializers.FloatField(read_only=True)
|
||||
in_stock = serializers.FloatField(read_only=True)
|
||||
variant_stock = serializers.FloatField(read_only=True)
|
||||
ordering = serializers.FloatField(read_only=True)
|
||||
stock_item_count = serializers.IntegerField(read_only=True)
|
||||
suppliers = serializers.IntegerField(read_only=True)
|
||||
@ -463,6 +499,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
'full_name',
|
||||
'image',
|
||||
'in_stock',
|
||||
'variant_stock',
|
||||
'ordering',
|
||||
'building',
|
||||
'IPN',
|
||||
@ -577,6 +614,11 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
purchase_price_range = serializers.SerializerMethodField()
|
||||
|
||||
# Annotated fields for available stock
|
||||
available_stock = serializers.FloatField(read_only=True)
|
||||
available_substitute_stock = serializers.FloatField(read_only=True)
|
||||
available_variant_stock = serializers.FloatField(read_only=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# part_detail and sub_part_detail serializers are only included if requested.
|
||||
# This saves a bunch of database requests
|
||||
@ -609,10 +651,158 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
queryset = queryset.prefetch_related('sub_part')
|
||||
queryset = queryset.prefetch_related('sub_part__category')
|
||||
queryset = queryset.prefetch_related('sub_part__stock_items')
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
'sub_part__stock_items',
|
||||
'sub_part__stock_items__allocations',
|
||||
'sub_part__stock_items__sales_order_allocations',
|
||||
)
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
'substitutes',
|
||||
'substitutes__part__stock_items',
|
||||
)
|
||||
|
||||
queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
|
||||
return queryset
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""
|
||||
Annotate the BomItem queryset with extra information:
|
||||
|
||||
Annotations:
|
||||
available_stock: The amount of stock available for the sub_part Part object
|
||||
"""
|
||||
|
||||
"""
|
||||
Construct an "available stock" quantity:
|
||||
available_stock = total_stock - build_order_allocations - sales_order_allocations
|
||||
"""
|
||||
|
||||
build_order_filter = Q(build__status__in=BuildStatus.ACTIVE_CODES)
|
||||
sales_order_filter = Q(
|
||||
line__order__status__in=SalesOrderStatus.OPEN,
|
||||
shipment__shipment_date=None,
|
||||
)
|
||||
|
||||
# Calculate "total stock" for the referenced sub_part
|
||||
# Calculate the "build_order_allocations" for the sub_part
|
||||
# Note that these fields are only aliased, not annotated
|
||||
queryset = queryset.alias(
|
||||
total_stock=Coalesce(
|
||||
SubquerySum(
|
||||
'sub_part__stock_items__quantity',
|
||||
filter=StockItem.IN_STOCK_FILTER
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
allocated_to_sales_orders=Coalesce(
|
||||
SubquerySum(
|
||||
'sub_part__stock_items__sales_order_allocations__quantity',
|
||||
filter=sales_order_filter,
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
allocated_to_build_orders=Coalesce(
|
||||
SubquerySum(
|
||||
'sub_part__stock_items__allocations__quantity',
|
||||
filter=build_order_filter,
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
)
|
||||
|
||||
# Calculate 'available_stock' based on previously annotated fields
|
||||
queryset = queryset.annotate(
|
||||
available_stock=ExpressionWrapper(
|
||||
F('total_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Extract similar information for any 'substitute' parts
|
||||
queryset = queryset.alias(
|
||||
substitute_stock=Coalesce(
|
||||
SubquerySum(
|
||||
'substitutes__part__stock_items__quantity',
|
||||
filter=StockItem.IN_STOCK_FILTER,
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
substitute_build_allocations=Coalesce(
|
||||
SubquerySum(
|
||||
'substitutes__part__stock_items__allocations__quantity',
|
||||
filter=build_order_filter,
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
substitute_sales_allocations=Coalesce(
|
||||
SubquerySum(
|
||||
'substitutes__part__stock_items__sales_order_allocations__quantity',
|
||||
filter=sales_order_filter,
|
||||
),
|
||||
Decimal(0),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
)
|
||||
|
||||
# Calculate 'available_substitute_stock' field
|
||||
queryset = queryset.annotate(
|
||||
available_substitute_stock=ExpressionWrapper(
|
||||
F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Annotate the queryset with 'available variant stock' information
|
||||
variant_stock_query = StockItem.objects.filter(
|
||||
part__tree_id=OuterRef('sub_part__tree_id'),
|
||||
part__lft__gt=OuterRef('sub_part__lft'),
|
||||
part__rght__lt=OuterRef('sub_part__rght'),
|
||||
).filter(StockItem.IN_STOCK_FILTER)
|
||||
|
||||
queryset = queryset.alias(
|
||||
variant_stock_total=Coalesce(
|
||||
Subquery(
|
||||
variant_stock_query.annotate(
|
||||
total=Func(F('quantity'), function='SUM', output_field=FloatField())
|
||||
).values('total')),
|
||||
0,
|
||||
output_field=FloatField()
|
||||
),
|
||||
variant_stock_build_order_allocations=Coalesce(
|
||||
Subquery(
|
||||
variant_stock_query.annotate(
|
||||
total=Func(F('sales_order_allocations__quantity'), function='SUM', output_field=FloatField()),
|
||||
).values('total')),
|
||||
0,
|
||||
output_field=FloatField(),
|
||||
),
|
||||
variant_stock_sales_order_allocations=Coalesce(
|
||||
Subquery(
|
||||
variant_stock_query.annotate(
|
||||
total=Func(F('allocations__quantity'), function='SUM', output_field=FloatField()),
|
||||
).values('total')),
|
||||
0,
|
||||
output_field=FloatField(),
|
||||
)
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
available_variant_stock=ExpressionWrapper(
|
||||
F('variant_stock_total') - F('variant_stock_build_order_allocations') - F('variant_stock_sales_order_allocations'),
|
||||
output_field=FloatField(),
|
||||
)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_purchase_price_range(self, obj):
|
||||
""" Return purchase price range """
|
||||
|
||||
@ -682,6 +872,12 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
'substitutes',
|
||||
'price_range',
|
||||
'validated',
|
||||
|
||||
# Annotated fields describing available quantity
|
||||
'available_stock',
|
||||
'available_substitute_stock',
|
||||
'available_variant_stock',
|
||||
|
||||
]
|
||||
|
||||
|
||||
|
@ -1,10 +0,0 @@
|
||||
{% load i18n %}
|
||||
|
||||
<div class="markdownx row">
|
||||
<div class="markdown col-md-6">
|
||||
{% include 'django/forms/widgets/textarea.html' %}
|
||||
</div>
|
||||
<div class="markdown col-md-6">
|
||||
<div class="markdownx-preview"></div>
|
||||
</div>
|
||||
</div>
|
@ -192,6 +192,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-stock'>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Stock Items" %}</h4>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% include "stock_table.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-parameters'>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Part Parameters" %}</h4>
|
||||
@ -228,6 +237,21 @@
|
||||
{{ block.super }}
|
||||
|
||||
{% if category %}
|
||||
|
||||
onPanelLoad('stock', function() {
|
||||
loadStockTable(
|
||||
$('#stock-table'),
|
||||
{
|
||||
params: {
|
||||
category: {{ category.pk }},
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
supplier_part_detail: true,
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
onPanelLoad('parameters', function() {
|
||||
loadParametricPartTable(
|
||||
"#parametric-part-table",
|
||||
|
@ -14,6 +14,8 @@
|
||||
{% include "sidebar_link.html" with url=url text=text icon="fa-file-upload" %}
|
||||
{% endif %}
|
||||
{% if category %}
|
||||
{% trans "Stock Items" as text %}
|
||||
{% include "sidebar_item.html" with label='stock' text=text icon='fa-boxes' %}
|
||||
{% trans "Parameters" as text %}
|
||||
{% include "sidebar_item.html" with label="parameters" text=text icon="fa-tasks" %}
|
||||
{% endif %}
|
@ -3,7 +3,6 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load markdownify %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'part/part_sidebar.html' %}
|
||||
@ -125,8 +124,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %}
|
||||
{% if show_price_history %}
|
||||
{% if part.purchaseable or part.salable %}
|
||||
<div class='panel panel-hidden' id='panel-pricing'>
|
||||
{% include "part/prices.html" %}
|
||||
</div>
|
||||
@ -134,24 +132,16 @@
|
||||
|
||||
<div class='panel panel-hidden' id='panel-part-notes'>
|
||||
<div class='panel-heading'>
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<h4>{% trans "Notes" %}</h4>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<div class='btn-group float-right'>
|
||||
<button type='button' id='edit-notes' title='{% trans "Edit Notes" %}' class='btn btn-outline-secondary'>
|
||||
<span class='fas fa-edit'>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Part Notes" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "notes_buttons.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if part.notes %}
|
||||
{{ part.notes | markdownify }}
|
||||
{% endif %}
|
||||
<textarea id='part-notes'></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -419,6 +409,18 @@
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
// Load the "notes" tab
|
||||
onPanelLoad('part-notes', function() {
|
||||
|
||||
setupNotesField(
|
||||
'part-notes',
|
||||
'{% url "api-part-detail" part.pk %}',
|
||||
{
|
||||
editable: {% if roles.part.change %}true{% else %}false{% endif %},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Load the "scheduling" tab
|
||||
onPanelLoad('scheduling', function() {
|
||||
loadPartSchedulingChart('part-schedule-chart', {{ part.pk }});
|
||||
@ -832,36 +834,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
$('#edit-notes').click(function() {
|
||||
constructForm('{% url "api-part-detail" part.pk %}', {
|
||||
fields: {
|
||||
notes: {
|
||||
multiline: true,
|
||||
}
|
||||
},
|
||||
title: '{% trans "Edit Part Notes" %}',
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
|
||||
$(".slidey").change(function() {
|
||||
var field = $(this).attr('fieldname');
|
||||
|
||||
var checked = $(this).prop('checked');
|
||||
|
||||
var data = {};
|
||||
|
||||
data[field] = checked;
|
||||
// Update the particular field
|
||||
inventreePut("{% url 'api-part-detail' part.id %}",
|
||||
data,
|
||||
{
|
||||
method: 'PATCH',
|
||||
reloadOnSuccess: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
onPanelLoad("part-parameters", function() {
|
||||
loadPartParameterTable(
|
||||
'#parameter-table',
|
||||
@ -1036,7 +1008,7 @@
|
||||
pb_url_slug: 'internal-price',
|
||||
pb_url: '{% url 'api-part-internal-price-list' %}',
|
||||
pb_new_btn: $('#new-internal-price-break'),
|
||||
pb_new_url: '{% url 'internal-price-break-create' %}',
|
||||
pb_new_url: '{% url 'api-part-internal-price-list' %}',
|
||||
linkedGraph: $('#InternalPriceBreakChart'),
|
||||
},
|
||||
);
|
||||
@ -1052,7 +1024,7 @@
|
||||
pb_url_slug: 'sale-price',
|
||||
pb_url: "{% url 'api-part-sale-price-list' %}",
|
||||
pb_new_btn: $('#new-price-break'),
|
||||
pb_new_url: '{% url 'sale-price-break-create' %}',
|
||||
pb_new_url: '{% url 'api-part-sale-price-list' %}',
|
||||
linkedGraph: $('#SalePriceBreakChart'),
|
||||
},
|
||||
);
|
||||
|
@ -252,7 +252,6 @@
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not part.is_template %}
|
||||
{% if part.assembly %}
|
||||
<tr>
|
||||
<td><span class='fas fa-tools'></span></td>
|
||||
@ -266,7 +265,6 @@
|
||||
<td>{% decimal quantity_being_built %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endblock details_right %}
|
||||
|
@ -4,7 +4,6 @@
|
||||
|
||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||
{% settings_value 'PART_SHOW_RELATED' as show_related %}
|
||||
{% settings_value "PART_SHOW_PRICE_HISTORY" as show_price_history %}
|
||||
|
||||
{% trans "Parameters" as text %}
|
||||
{% include "sidebar_item.html" with label="part-parameters" text=text icon="fa-th-list" %}
|
||||
@ -28,7 +27,7 @@
|
||||
{% trans "Used In" as text %}
|
||||
{% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %}
|
||||
{% endif %}
|
||||
{% if show_price_history %}
|
||||
{% if part.purchaseable or part.salable %}
|
||||
{% trans "Pricing" as text %}
|
||||
{% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %}
|
||||
{% endif %}
|
||||
|
@ -3,6 +3,9 @@
|
||||
{% load crispy_forms_tags %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||
|
||||
{% if show_price_history %}
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Pricing Information" %}</h4>
|
||||
</div>
|
||||
@ -43,7 +46,7 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if part.bom_count > 0 %}
|
||||
{% if part.assembly and part.bom_count > 0 %}
|
||||
{% if min_total_bom_price %}
|
||||
<tr>
|
||||
<td><strong>{% trans 'BOM Pricing' %}</strong>
|
||||
@ -147,7 +150,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||
{% endif %}
|
||||
|
||||
{% if part.purchaseable and roles.purchase_order.view %}
|
||||
<a class="anchor" id="supplier-cost"></a>
|
||||
@ -170,7 +173,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if price_history %}
|
||||
{% if show_price_history %}
|
||||
<a class="anchor" id="purchase-price"></a>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Purchase Price" %}
|
||||
@ -279,6 +282,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if show_price_history %}
|
||||
<a class="anchor" id="sale-price"></a>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Sale Price" %}
|
||||
@ -298,3 +302,5 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
@ -9,7 +9,7 @@ from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import BuildStatus, StockStatus
|
||||
from InvenTree.status_codes import BuildStatus, StockStatus, PurchaseOrderStatus
|
||||
|
||||
from part.models import Part, PartCategory
|
||||
from part.models import BomItem, BomItemSubstitute
|
||||
@ -567,6 +567,185 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(response.data['name'], name)
|
||||
self.assertEqual(response.data['description'], description)
|
||||
|
||||
def test_template_filters(self):
|
||||
"""
|
||||
Unit tests for API filters related to template parts:
|
||||
|
||||
- variant_of : Return children of specified part
|
||||
- ancestor : Return descendants of specified part
|
||||
|
||||
Uses the 'chair template' part (pk=10000)
|
||||
"""
|
||||
|
||||
# Rebuild the MPTT structure before running these tests
|
||||
Part.objects.rebuild()
|
||||
|
||||
url = reverse('api-part-list')
|
||||
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'variant_of': 10000,
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
# 3 direct children of template part
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'ancestor': 10000,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
# 4 total descendants
|
||||
self.assertEqual(len(response.data), 4)
|
||||
|
||||
# Use the 'green chair' as our reference
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'variant_of': 10003,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'ancestor': 10003,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
# Add some more variants
|
||||
|
||||
p = Part.objects.get(pk=10004)
|
||||
|
||||
for i in range(100):
|
||||
Part.objects.create(
|
||||
name=f'Chair variant {i}',
|
||||
description='A new chair variant',
|
||||
variant_of=p,
|
||||
)
|
||||
|
||||
# There should still be only one direct variant
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'variant_of': 10003,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
# However, now should be 101 descendants
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'ancestor': 10003,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 101)
|
||||
|
||||
def test_variant_stock(self):
|
||||
"""
|
||||
Unit tests for the 'variant_stock' annotation,
|
||||
which provides a stock count for *variant* parts
|
||||
"""
|
||||
|
||||
# Ensure the MPTT structure is in a known state before running tests
|
||||
Part.objects.rebuild()
|
||||
|
||||
# Initially, there are no "chairs" in stock,
|
||||
# so each 'chair' template should report variant_stock=0
|
||||
url = reverse('api-part-list')
|
||||
|
||||
# Look at the "detail" URL for the master chair template
|
||||
response = self.get('/api/part/10000/', {}, expected_code=200)
|
||||
|
||||
# This part should report 'zero' as variant stock
|
||||
self.assertEqual(response.data['variant_stock'], 0)
|
||||
|
||||
# Grab a list of all variant chairs *under* the master template
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'ancestor': 10000,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
# 4 total descendants
|
||||
self.assertEqual(len(response.data), 4)
|
||||
|
||||
for variant in response.data:
|
||||
self.assertEqual(variant['variant_stock'], 0)
|
||||
|
||||
# Now, let's make some variant stock
|
||||
for variant in Part.objects.get(pk=10000).get_descendants(include_self=False):
|
||||
StockItem.objects.create(
|
||||
part=variant,
|
||||
quantity=100,
|
||||
)
|
||||
|
||||
response = self.get('/api/part/10000/', {}, expected_code=200)
|
||||
|
||||
self.assertEqual(response.data['in_stock'], 0)
|
||||
self.assertEqual(response.data['variant_stock'], 400)
|
||||
|
||||
# Check that each variant reports the correct stock quantities
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'ancestor': 10000,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
expected_variant_stock = {
|
||||
10001: 0,
|
||||
10002: 0,
|
||||
10003: 100,
|
||||
10004: 0,
|
||||
}
|
||||
|
||||
for variant in response.data:
|
||||
self.assertEqual(variant['in_stock'], 100)
|
||||
self.assertEqual(variant['variant_stock'], expected_variant_stock[variant['pk']])
|
||||
|
||||
# Add some 'sub variants' for the green chair variant
|
||||
green_chair = Part.objects.get(pk=10004)
|
||||
|
||||
for i in range(10):
|
||||
gcv = Part.objects.create(
|
||||
name=f"GC Var {i}",
|
||||
description="Green chair variant",
|
||||
variant_of=green_chair,
|
||||
)
|
||||
|
||||
StockItem.objects.create(
|
||||
part=gcv,
|
||||
quantity=50,
|
||||
)
|
||||
|
||||
# Spot check of some values
|
||||
response = self.get('/api/part/10000/', {})
|
||||
self.assertEqual(response.data['variant_stock'], 900)
|
||||
|
||||
response = self.get('/api/part/10004/', {})
|
||||
self.assertEqual(response.data['variant_stock'], 500)
|
||||
|
||||
|
||||
class PartDetailTests(InvenTreeAPITestCase):
|
||||
"""
|
||||
@ -578,7 +757,12 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
'part',
|
||||
'location',
|
||||
'bom',
|
||||
'company',
|
||||
'test_templates',
|
||||
'manufacturer_part',
|
||||
'supplier_part',
|
||||
'order',
|
||||
'stock',
|
||||
]
|
||||
|
||||
roles = [
|
||||
@ -805,6 +989,38 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
# And now check that the image has been set
|
||||
p = Part.objects.get(pk=pk)
|
||||
|
||||
def test_details(self):
|
||||
"""
|
||||
Test that the required details are available
|
||||
"""
|
||||
|
||||
p = Part.objects.get(pk=1)
|
||||
|
||||
url = reverse('api-part-detail', kwargs={'pk': 1})
|
||||
|
||||
data = self.get(url, expected_code=200).data
|
||||
|
||||
# How many parts are 'on order' for this part?
|
||||
lines = order.models.PurchaseOrderLineItem.objects.filter(
|
||||
part__part__pk=1,
|
||||
order__status__in=PurchaseOrderStatus.OPEN,
|
||||
)
|
||||
|
||||
on_order = 0
|
||||
|
||||
# Calculate the "on_order" quantity by hand,
|
||||
# to check it matches the API value
|
||||
for line in lines:
|
||||
on_order += line.quantity
|
||||
on_order -= line.received
|
||||
|
||||
self.assertEqual(on_order, data['ordering'])
|
||||
self.assertEqual(on_order, p.on_order)
|
||||
|
||||
# Some other checks
|
||||
self.assertEqual(data['in_stock'], 9000)
|
||||
self.assertEqual(data['unallocated_stock'], 9000)
|
||||
|
||||
|
||||
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
"""
|
||||
@ -1123,6 +1339,12 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]['pk'], bom_item.pk)
|
||||
|
||||
# Each item in response should contain expected keys
|
||||
for el in response.data:
|
||||
|
||||
for key in ['available_stock', 'available_substitute_stock']:
|
||||
self.assertTrue(key in el)
|
||||
|
||||
def test_get_bom_detail(self):
|
||||
"""
|
||||
Get the detail view for a single BomItem object
|
||||
@ -1132,6 +1354,26 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
|
||||
response = self.get(url, expected_code=200)
|
||||
|
||||
expected_values = [
|
||||
'allow_variants',
|
||||
'inherited',
|
||||
'note',
|
||||
'optional',
|
||||
'overage',
|
||||
'pk',
|
||||
'part',
|
||||
'quantity',
|
||||
'reference',
|
||||
'sub_part',
|
||||
'substitutes',
|
||||
'validated',
|
||||
'available_stock',
|
||||
'available_substitute_stock',
|
||||
]
|
||||
|
||||
for key in expected_values:
|
||||
self.assertTrue(key in response.data)
|
||||
|
||||
self.assertEqual(int(float(response.data['quantity'])), 25)
|
||||
|
||||
# Increase the quantity
|
||||
@ -1319,6 +1561,21 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
response = self.get(url, expected_code=200)
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
# The BomItem detail endpoint should now also reflect the substitute data
|
||||
data = self.get(
|
||||
reverse('api-bom-item-detail', kwargs={'pk': bom_item.pk}),
|
||||
expected_code=200
|
||||
).data
|
||||
|
||||
# 5 substitute parts
|
||||
self.assertEqual(len(data['substitutes']), 5)
|
||||
|
||||
# 5 x 1,000 stock quantity
|
||||
self.assertEqual(data['available_substitute_stock'], 5000)
|
||||
|
||||
# 9,000 stock directly available
|
||||
self.assertEqual(data['available_stock'], 9000)
|
||||
|
||||
def test_bom_item_uses(self):
|
||||
"""
|
||||
Tests for the 'uses' field
|
||||
@ -1372,6 +1629,44 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
|
||||
self.assertEqual(len(response.data), i)
|
||||
|
||||
def test_bom_variant_stock(self):
|
||||
"""
|
||||
Test for 'available_variant_stock' annotation
|
||||
"""
|
||||
|
||||
Part.objects.rebuild()
|
||||
|
||||
# BOM item we are interested in
|
||||
bom_item = BomItem.objects.get(pk=1)
|
||||
|
||||
response = self.get('/api/bom/1/', {}, expected_code=200)
|
||||
|
||||
# Initially, no variant stock available
|
||||
self.assertEqual(response.data['available_variant_stock'], 0)
|
||||
|
||||
# Create some 'variants' of the referenced sub_part
|
||||
bom_item.sub_part.is_template = True
|
||||
bom_item.sub_part.save()
|
||||
|
||||
for i in range(10):
|
||||
# Create a variant part
|
||||
vp = Part.objects.create(
|
||||
name=f"Var {i}",
|
||||
description="Variant part",
|
||||
variant_of=bom_item.sub_part,
|
||||
)
|
||||
|
||||
# Create a stock item
|
||||
StockItem.objects.create(
|
||||
part=vp,
|
||||
quantity=100,
|
||||
)
|
||||
|
||||
# There should now be variant stock available
|
||||
response = self.get('/api/bom/1/', {}, expected_code=200)
|
||||
|
||||
self.assertEqual(response.data['available_variant_stock'], 1000)
|
||||
|
||||
|
||||
class PartParameterTest(InvenTreeAPITestCase):
|
||||
"""
|
||||
|
@ -349,6 +349,26 @@ class PartSettingsTest(TestCase):
|
||||
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C')
|
||||
part.full_clean()
|
||||
|
||||
# Any duplicate IPN should raise an error
|
||||
Part.objects.create(name='xyz', revision='1', description='A part', IPN='UNIQUE')
|
||||
|
||||
# Case insensitive, so variations on spelling should throw an error
|
||||
for ipn in ['UNiquE', 'uniQuE', 'unique']:
|
||||
with self.assertRaises(ValidationError):
|
||||
Part.objects.create(name='xyz', revision='2', description='A part', IPN=ipn)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
Part.objects.create(name='zyx', description='A part', IPN='UNIQUE')
|
||||
|
||||
# However, *blank* / empty IPN values should be allowed, even if duplicates are not
|
||||
# Note that leading / trailling whitespace characters are trimmed, too
|
||||
Part.objects.create(name='abc', revision='1', description='A part', IPN=None)
|
||||
Part.objects.create(name='abc', revision='2', description='A part', IPN='')
|
||||
Part.objects.create(name='abc', revision='3', description='A part', IPN=None)
|
||||
Part.objects.create(name='abc', revision='4', description='A part', IPN=' ')
|
||||
Part.objects.create(name='abc', revision='5', description='A part', IPN=' ')
|
||||
Part.objects.create(name='abc', revision='6', description='A part', IPN=' ')
|
||||
|
||||
|
||||
class PartSubscriptionTests(TestCase):
|
||||
|
||||
|
@ -13,18 +13,6 @@ from django.conf.urls import url, include
|
||||
from . import views
|
||||
|
||||
|
||||
sale_price_break_urls = [
|
||||
url(r'^new/', views.PartSalePriceBreakCreate.as_view(), name='sale-price-break-create'),
|
||||
url(r'^(?P<pk>\d+)/edit/', views.PartSalePriceBreakEdit.as_view(), name='sale-price-break-edit'),
|
||||
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'),
|
||||
@ -86,12 +74,6 @@ part_urls = [
|
||||
# Part category
|
||||
url(r'^category/', include(category_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 parameters
|
||||
url(r'^parameter/', include(part_parameter_urls)),
|
||||
|
||||
|
@ -18,7 +18,6 @@ from django.forms import HiddenInput
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
|
||||
@ -33,7 +32,6 @@ from decimal import Decimal
|
||||
from .models import PartCategory, Part
|
||||
from .models import PartParameterTemplate
|
||||
from .models import PartCategoryParameterTemplate
|
||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from company.models import SupplierPart
|
||||
@ -389,8 +387,12 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
|
||||
|
||||
context.update(**ctx)
|
||||
|
||||
show_price_history = InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False)
|
||||
|
||||
context['show_price_history'] = show_price_history
|
||||
|
||||
# Pricing information
|
||||
if InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False):
|
||||
if show_price_history:
|
||||
ctx = self.get_pricing(self.get_quantity())
|
||||
ctx['form'] = self.form_class(initial=self.get_initials())
|
||||
|
||||
@ -1226,102 +1228,3 @@ class CategoryParameterTemplateDelete(AjaxDeleteView):
|
||||
return None
|
||||
|
||||
return self.object
|
||||
|
||||
|
||||
class PartSalePriceBreakCreate(AjaxCreateView):
|
||||
"""
|
||||
View for creating a sale price break for a part
|
||||
"""
|
||||
|
||||
model = PartSellPriceBreak
|
||||
form_class = part_forms.EditPartSalePriceBreakForm
|
||||
ajax_form_title = _('Add Price Break')
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'success': _('Added new price break')
|
||||
}
|
||||
|
||||
def get_part(self):
|
||||
try:
|
||||
part = Part.objects.get(id=self.request.GET.get('part'))
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
part = None
|
||||
|
||||
if part is None:
|
||||
try:
|
||||
part = Part.objects.get(id=self.request.POST.get('part'))
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
part = None
|
||||
|
||||
return part
|
||||
|
||||
def get_form(self):
|
||||
|
||||
form = super(AjaxCreateView, self).get_form()
|
||||
form.fields['part'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
||||
def get_initial(self):
|
||||
|
||||
initials = super(AjaxCreateView, self).get_initial()
|
||||
|
||||
initials['part'] = self.get_part()
|
||||
|
||||
default_currency = inventree_settings.currency_code_default()
|
||||
currency = CURRENCIES.get(default_currency, None)
|
||||
|
||||
if currency is not None:
|
||||
initials['price'] = [1.0, currency]
|
||||
|
||||
return initials
|
||||
|
||||
|
||||
class PartSalePriceBreakEdit(AjaxUpdateView):
|
||||
""" View for editing a sale price break """
|
||||
|
||||
model = PartSellPriceBreak
|
||||
form_class = part_forms.EditPartSalePriceBreakForm
|
||||
ajax_form_title = _('Edit Price Break')
|
||||
|
||||
def get_form(self):
|
||||
|
||||
form = super().get_form()
|
||||
form.fields['part'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
||||
|
||||
class PartSalePriceBreakDelete(AjaxDeleteView):
|
||||
""" View for deleting a sale price break """
|
||||
|
||||
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'
|
||||
|
Reference in New Issue
Block a user