mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-20 22:06:28 +00:00
Merge branch 'master' of git://github.com/inventree/InvenTree into part_ipn_slug
This commit is contained in:
@ -377,6 +377,7 @@ class PartList(generics.ListCreateAPIView):
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = part_serializers.PartSerializer.prefetch_queryset(queryset)
|
||||
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
|
||||
|
||||
|
@ -129,10 +129,17 @@ class EditPartForm(HelperForm):
|
||||
'IPN': 'fa-hashtag',
|
||||
}
|
||||
|
||||
deep_copy = forms.BooleanField(required=False,
|
||||
initial=True,
|
||||
help_text=_("Perform 'deep copy' which will duplicate all BOM data for this part"),
|
||||
widget=forms.HiddenInput())
|
||||
bom_copy = forms.BooleanField(required=False,
|
||||
initial=True,
|
||||
help_text=_("Duplicate all BOM data for this part"),
|
||||
label=_('Copy BOM'),
|
||||
widget=forms.HiddenInput())
|
||||
|
||||
parameters_copy = forms.BooleanField(required=False,
|
||||
initial=True,
|
||||
help_text=_("Duplicate all parameter data for this part"),
|
||||
label=_('Copy Parameters'),
|
||||
widget=forms.HiddenInput())
|
||||
|
||||
confirm_creation = forms.BooleanField(required=False,
|
||||
initial=False,
|
||||
@ -142,7 +149,8 @@ class EditPartForm(HelperForm):
|
||||
class Meta:
|
||||
model = Part
|
||||
fields = [
|
||||
'deep_copy',
|
||||
'bom_copy',
|
||||
'parameters_copy',
|
||||
'confirm_creation',
|
||||
'category',
|
||||
'name',
|
||||
|
@ -1041,6 +1041,7 @@ class Part(MPTTModel):
|
||||
Keyword Args:
|
||||
image: If True, copies Part image (default = True)
|
||||
bom: If True, copies BOM data (default = False)
|
||||
parameters: If True, copies Parameters data (default = True)
|
||||
"""
|
||||
|
||||
# Copy the part image
|
||||
@ -1058,6 +1059,17 @@ class Part(MPTTModel):
|
||||
item.pk = None
|
||||
item.save()
|
||||
|
||||
# Copy the parameters data
|
||||
if kwargs.get('parameters', True):
|
||||
# Get template part parameters
|
||||
parameters = other.get_parameters()
|
||||
# Copy template part parameters to new variant part
|
||||
for parameter in parameters:
|
||||
PartParameter.create(part=self,
|
||||
template=parameter.template,
|
||||
data=parameter.data,
|
||||
save=True)
|
||||
|
||||
# Copy the fields that aren't available in the duplicate form
|
||||
self.salable = other.salable
|
||||
self.assembly = other.assembly
|
||||
@ -1402,6 +1414,13 @@ class PartParameter(models.Model):
|
||||
|
||||
data = models.CharField(max_length=500, help_text=_('Parameter Value'))
|
||||
|
||||
@classmethod
|
||||
def create(cls, part, template, data, save=False):
|
||||
part_parameter = cls(part=part, template=template, data=data)
|
||||
if save:
|
||||
part_parameter.save()
|
||||
return part_parameter
|
||||
|
||||
|
||||
class BomItem(models.Model):
|
||||
""" A BomItem links a part to its component items.
|
||||
|
@ -13,12 +13,16 @@ from .models import PartParameter, PartParameterTemplate
|
||||
from .models import PartAttachment
|
||||
from .models import PartTestTemplate
|
||||
|
||||
from stock.models import StockItem
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Q, Sum
|
||||
from sql_util.utils import SubquerySum, SubqueryCount
|
||||
|
||||
from django.db.models import Q
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from InvenTree.status_codes import StockStatus, PurchaseOrderStatus, BuildStatus
|
||||
from InvenTree.status_codes import PurchaseOrderStatus, BuildStatus
|
||||
from InvenTree.serializers import InvenTreeModelSerializer
|
||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||
|
||||
@ -189,29 +193,45 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
to reduce database trips.
|
||||
"""
|
||||
|
||||
# Filter to limit stock items to "available"
|
||||
stock_filter = Q(stock_items__status__in=StockStatus.AVAILABLE_CODES)
|
||||
# Annotate with the total 'in stock' quantity
|
||||
queryset = queryset.annotate(
|
||||
in_stock=Coalesce(
|
||||
SubquerySum('stock_items__quantity', filter=StockItem.IN_STOCK_FILTER),
|
||||
Decimal(0)
|
||||
),
|
||||
)
|
||||
|
||||
# Filter to limit orders to "open"
|
||||
order_filter = Q(supplier_parts__purchase_order_line_items__order__status__in=PurchaseOrderStatus.OPEN)
|
||||
# Annotate with the total number of stock items
|
||||
queryset = queryset.annotate(
|
||||
stock_item_count=SubqueryCount('stock_items')
|
||||
)
|
||||
|
||||
# Filter to limit builds to "active"
|
||||
build_filter = Q(builds__status__in=BuildStatus.ACTIVE_CODES)
|
||||
build_filter = Q(
|
||||
status__in=BuildStatus.ACTIVE_CODES
|
||||
)
|
||||
|
||||
# Annotate the number total stock count
|
||||
# Annotate with the total 'building' quantity
|
||||
queryset = queryset.annotate(
|
||||
in_stock=Coalesce(Sum('stock_items__quantity', filter=stock_filter, distinct=True), Decimal(0)),
|
||||
ordering=Coalesce(Sum(
|
||||
'supplier_parts__purchase_order_line_items__quantity',
|
||||
filter=order_filter,
|
||||
distinct=True
|
||||
), Decimal(0)) - Coalesce(Sum(
|
||||
'supplier_parts__purchase_order_line_items__received',
|
||||
filter=order_filter,
|
||||
distinct=True
|
||||
), Decimal(0)),
|
||||
building=Coalesce(
|
||||
Sum('builds__quantity', filter=build_filter, distinct=True), Decimal(0)
|
||||
SubquerySum('builds__quantity', filter=build_filter),
|
||||
Decimal(0),
|
||||
)
|
||||
)
|
||||
|
||||
# Filter to limit orders to "open"
|
||||
order_filter = Q(
|
||||
order__status__in=PurchaseOrderStatus.OPEN
|
||||
)
|
||||
|
||||
# Annotate with the total 'on order' quantity
|
||||
queryset = queryset.annotate(
|
||||
ordering=Coalesce(
|
||||
SubquerySum('supplier_parts__purchase_order_line_items__quantity', filter=order_filter),
|
||||
Decimal(0),
|
||||
) - Coalesce(
|
||||
SubquerySum('supplier_parts__purchase_order_line_items__received', filter=order_filter),
|
||||
Decimal(0),
|
||||
)
|
||||
)
|
||||
|
||||
@ -231,6 +251,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
in_stock = serializers.FloatField(read_only=True)
|
||||
ordering = serializers.FloatField(read_only=True)
|
||||
building = serializers.FloatField(read_only=True)
|
||||
stock_item_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
image = serializers.CharField(source='get_image_url', read_only=True)
|
||||
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||
@ -273,6 +294,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
'revision',
|
||||
'salable',
|
||||
'starred',
|
||||
'stock_item_count',
|
||||
'thumbnail',
|
||||
'trackable',
|
||||
'units',
|
||||
|
@ -15,14 +15,14 @@
|
||||
{% endif %}
|
||||
<p>
|
||||
<div class='btn-group action-buttons'>
|
||||
<button class='btn btn-default' id='cat-create' title='Create new part category'>
|
||||
<button class='btn btn-default' id='cat-create' title='{% trans "Create new part category" %}'>
|
||||
<span class='fas fa-plus-circle icon-green'/>
|
||||
</button>
|
||||
{% if category %}
|
||||
<button class='btn btn-default' id='cat-edit' title='Edit part category'>
|
||||
<button class='btn btn-default' id='cat-edit' title='{% trans "Edit part category" %}'>
|
||||
<span class='fas fa-edit icon-blue'/>
|
||||
</button>
|
||||
<button class='btn btn-default' id='cat-delete' title='Delete part category'>
|
||||
<button class='btn btn-default' id='cat-delete' title='{% trans "Delete part category" %}'>
|
||||
<span class='fas fa-trash-alt icon-red'/>
|
||||
</button>
|
||||
{% endif %}
|
||||
@ -97,14 +97,14 @@
|
||||
|
||||
<div id='button-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style="float: right;">
|
||||
<button class='btn btn-default' id='part-export' title='Export Part Data'>Export</button>
|
||||
<button class='btn btn-default' id='part-export' title='{% trans "Export Part Data" %}'>{% trans "Export" %}</button>
|
||||
<button class='btn btn-success' id='part-create'>New Part</button>
|
||||
<div class='btn dropdown'>
|
||||
<button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">Options<span class='caret'></span></button>
|
||||
<button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">{% trans "Options" %}<span class='caret'></span></button>
|
||||
<ul class='dropdown-menu'>
|
||||
<li><a href='#' id='multi-part-category' title='Set category'>Set Category</a></li>
|
||||
<li><a href='#' id='multi-part-order' title='Order parts'>Order Parts</a></li>
|
||||
<li><a href='#' id='multi-part-export' title='Export'>Export Data</a></li>
|
||||
<li><a href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
|
||||
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
||||
<li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class='filter-list' id='filter-list-parts'>
|
||||
@ -148,14 +148,14 @@
|
||||
secondary: [
|
||||
{
|
||||
field: 'default_location',
|
||||
label: 'New Location',
|
||||
title: 'Create new location',
|
||||
label: '{% trans "New Location" %}',
|
||||
title: '{% trans "Create new location" %}',
|
||||
url: "{% url 'stock-location-create' %}",
|
||||
},
|
||||
{
|
||||
field: 'parent',
|
||||
label: 'New Category',
|
||||
title: 'Create new category',
|
||||
label: '{% trans "New Category" %}',
|
||||
title: '{% trans "Create new category" %}',
|
||||
url: "{% url 'category-create' %}",
|
||||
},
|
||||
]
|
||||
@ -183,14 +183,14 @@
|
||||
secondary: [
|
||||
{
|
||||
field: 'category',
|
||||
label: 'New Category',
|
||||
title: 'Create new Part Category',
|
||||
label: '{% trans "New Category" %}',
|
||||
title: '{% trans "Create new Part Category" %}',
|
||||
url: "{% url 'category-create' %}",
|
||||
},
|
||||
{
|
||||
field: 'default_location',
|
||||
label: 'New Location',
|
||||
title: 'Create new Stock Location',
|
||||
label: '{% trans "New Location" %}',
|
||||
title: '{% trans "Create new Stock Location" %}',
|
||||
url: "{% url 'stock-location-create' %}",
|
||||
}
|
||||
]
|
||||
@ -216,10 +216,12 @@
|
||||
{% endif %}
|
||||
|
||||
$('#cat-delete').click(function() {
|
||||
launchModalForm("{% url 'category-delete' category.id %}",
|
||||
{
|
||||
redirect: redirect
|
||||
});
|
||||
launchModalForm(
|
||||
"{% url 'category-delete' category.id %}",
|
||||
{
|
||||
redirect: redirect
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
{% endif %}
|
||||
|
@ -1,8 +1,15 @@
|
||||
from rest_framework.test import APITestCase
|
||||
from rest_framework import status
|
||||
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from part.models import Part
|
||||
from stock.models import StockItem
|
||||
from company.models import Company
|
||||
|
||||
from InvenTree.status_codes import StockStatus
|
||||
|
||||
|
||||
class PartAPITest(APITestCase):
|
||||
"""
|
||||
@ -213,3 +220,77 @@ class PartAPITest(APITestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class PartAPIAggregationTest(APITestCase):
|
||||
"""
|
||||
Tests to ensure that the various aggregation annotations are working correctly...
|
||||
"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'company',
|
||||
'part',
|
||||
'location',
|
||||
'bom',
|
||||
'test_templates',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
# Create a user for auth
|
||||
User = get_user_model()
|
||||
User.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
|
||||
self.client.login(username='testuser', password='password')
|
||||
|
||||
# Add a new part
|
||||
self.part = Part.objects.create(
|
||||
name='Banana',
|
||||
)
|
||||
|
||||
# Create some stock items associated with the part
|
||||
|
||||
# First create 600 units which are OK
|
||||
StockItem.objects.create(part=self.part, quantity=100)
|
||||
StockItem.objects.create(part=self.part, quantity=200)
|
||||
StockItem.objects.create(part=self.part, quantity=300)
|
||||
|
||||
# Now create another 400 units which are LOST
|
||||
StockItem.objects.create(part=self.part, quantity=400, status=StockStatus.LOST)
|
||||
|
||||
def get_part_data(self):
|
||||
url = reverse('api-part-list')
|
||||
|
||||
response = self.client.get(url, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
for part in response.data:
|
||||
if part['pk'] == self.part.pk:
|
||||
return part
|
||||
|
||||
# We should never get here!
|
||||
self.assertTrue(False)
|
||||
|
||||
def test_stock_quantity(self):
|
||||
"""
|
||||
Simple test for the stock quantity
|
||||
"""
|
||||
|
||||
data = self.get_part_data()
|
||||
|
||||
self.assertEqual(data['in_stock'], 600)
|
||||
self.assertEqual(data['stock_item_count'], 4)
|
||||
|
||||
# Add some more stock items!!
|
||||
for i in range(100):
|
||||
StockItem.objects.create(part=self.part, quantity=5)
|
||||
|
||||
# Add another stock item which is assigned to a customer (and shouldn't count)
|
||||
customer = Company.objects.get(pk=4)
|
||||
StockItem.objects.create(part=self.part, quantity=9999, customer=customer)
|
||||
|
||||
data = self.get_part_data()
|
||||
|
||||
self.assertEqual(data['in_stock'], 1100)
|
||||
self.assertEqual(data['stock_item_count'], 105)
|
||||
|
@ -303,6 +303,12 @@ class MakePartVariant(AjaxCreateView):
|
||||
# Hide some variant-related fields
|
||||
# form.fields['variant_of'].widget = HiddenInput()
|
||||
|
||||
# Force display of the 'bom_copy' widget
|
||||
form.fields['bom_copy'].widget = CheckboxInput()
|
||||
|
||||
# Force display of the 'parameters_copy' widget
|
||||
form.fields['parameters_copy'].widget = CheckboxInput()
|
||||
|
||||
return form
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
@ -329,8 +335,11 @@ class MakePartVariant(AjaxCreateView):
|
||||
data['text'] = str(part)
|
||||
data['url'] = part.get_absolute_url()
|
||||
|
||||
bom_copy = str2bool(request.POST.get('bom_copy', False))
|
||||
parameters_copy = str2bool(request.POST.get('parameters_copy', False))
|
||||
|
||||
# Copy relevent information from the template part
|
||||
part.deepCopy(part_template, bom=True)
|
||||
part.deepCopy(part_template, bom=bom_copy, parameters=parameters_copy)
|
||||
|
||||
return self.renderJsonResponse(request, form, data, context=context)
|
||||
|
||||
@ -377,15 +386,19 @@ class PartDuplicate(AjaxCreateView):
|
||||
def get_form(self):
|
||||
form = super(AjaxCreateView, self).get_form()
|
||||
|
||||
# Force display of the 'deep_copy' widget
|
||||
form.fields['deep_copy'].widget = CheckboxInput()
|
||||
# Force display of the 'bom_copy' widget
|
||||
form.fields['bom_copy'].widget = CheckboxInput()
|
||||
|
||||
# Force display of the 'parameters_copy' widget
|
||||
form.fields['parameters_copy'].widget = CheckboxInput()
|
||||
|
||||
return form
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
""" Capture the POST request for part duplication
|
||||
|
||||
- If the deep_copy object is set, copy all the BOM items too!
|
||||
- If the bom_copy object is set, copy all the BOM items too!
|
||||
- If the parameters_copy object is set, copy all the parameters too!
|
||||
"""
|
||||
|
||||
form = self.get_form()
|
||||
@ -428,12 +441,13 @@ class PartDuplicate(AjaxCreateView):
|
||||
data['pk'] = part.pk
|
||||
data['text'] = str(part)
|
||||
|
||||
deep_copy = str2bool(request.POST.get('deep_copy', False))
|
||||
bom_copy = str2bool(request.POST.get('bom_copy', False))
|
||||
parameters_copy = str2bool(request.POST.get('parameters_copy', False))
|
||||
|
||||
original = self.get_part_to_copy()
|
||||
|
||||
if original:
|
||||
part.deepCopy(original, bom=deep_copy)
|
||||
part.deepCopy(original, bom=bom_copy, parameters=parameters_copy)
|
||||
|
||||
try:
|
||||
data['url'] = part.get_absolute_url()
|
||||
@ -456,7 +470,9 @@ class PartDuplicate(AjaxCreateView):
|
||||
else:
|
||||
initials = super(AjaxCreateView, self).get_initial()
|
||||
|
||||
initials['deep_copy'] = str2bool(InvenTreeSetting.get_setting('part_deep_copy', True))
|
||||
initials['bom_copy'] = str2bool(InvenTreeSetting.get_setting('part_deep_copy', True))
|
||||
# Create new entry in InvenTree/common/kvp.yaml?
|
||||
initials['parameters_copy'] = str2bool(InvenTreeSetting.get_setting('part_deep_copy', True))
|
||||
|
||||
return initials
|
||||
|
||||
|
Reference in New Issue
Block a user