2
0
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:
eeintech
2020-09-05 11:42:33 -05:00
22 changed files with 969 additions and 532 deletions

View File

@ -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)

View File

@ -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',

View File

@ -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.

View File

@ -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',

View File

@ -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 %}

View File

@ -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)

View File

@ -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