mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Migrate "Convert to Variant" form to the API (#3183)
* Adds a Part API filter to limit query to valid conversion options for the specified part * Refactor 'exclude_tree' filter to use django-filter framework * Refactor the 'ancestor' filter * Refactoring more API filtering fields: - variant_of - in_bom_for * Adds API endpoint / view / serializer for converting a StockItem to variant * stock item conversion now perfomed via the API * Bump API version * Add unit tests for new filtering option on the Part list API endpoint * Adds unit test for "convert" API endpoint functionality
This commit is contained in:
parent
9b86bc6002
commit
8b464e4397
@ -2,11 +2,15 @@
|
||||
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 60
|
||||
INVENTREE_API_VERSION = 61
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v61 -> 2022-06-12 : https://github.com/inventree/InvenTree/pull/3183
|
||||
- Migrate the "Convert Stock Item" form class to use the API
|
||||
- There is now an API endpoint for converting a stock item to a valid variant
|
||||
|
||||
v60 -> 2022-06-08 : https://github.com/inventree/InvenTree/pull/3148
|
||||
- Add availability data fields to the SupplierPart model
|
||||
|
||||
|
@ -810,6 +810,53 @@ class PartFilter(rest_filters.FilterSet):
|
||||
|
||||
return queryset
|
||||
|
||||
convert_from = rest_filters.ModelChoiceFilter(label="Can convert from", queryset=Part.objects.all(), method='filter_convert_from')
|
||||
|
||||
def filter_convert_from(self, queryset, name, part):
|
||||
"""Limit the queryset to valid conversion options for the specified part"""
|
||||
conversion_options = part.get_conversion_options()
|
||||
|
||||
queryset = queryset.filter(pk__in=conversion_options)
|
||||
|
||||
return queryset
|
||||
|
||||
exclude_tree = rest_filters.ModelChoiceFilter(label="Exclude Part tree", queryset=Part.objects.all(), method='filter_exclude_tree')
|
||||
|
||||
def filter_exclude_tree(self, queryset, name, part):
|
||||
"""Exclude all parts and variants 'down' from the specified part from the queryset"""
|
||||
|
||||
children = part.get_descendants(include_self=True)
|
||||
|
||||
queryset = queryset.exclude(id__in=children)
|
||||
|
||||
return queryset
|
||||
|
||||
ancestor = rest_filters.ModelChoiceFilter(label='Ancestor', queryset=Part.objects.all(), method='filter_ancestor')
|
||||
|
||||
def filter_ancestor(self, queryset, name, part):
|
||||
"""Limit queryset to descendants of the specified ancestor part"""
|
||||
|
||||
descendants = part.get_descendants(include_self=False)
|
||||
queryset = queryset.filter(id__in=descendants)
|
||||
|
||||
return queryset
|
||||
|
||||
variant_of = rest_filters.ModelChoiceFilter(label='Variant Of', queryset=Part.objects.all(), method='filter_variant_of')
|
||||
|
||||
def filter_variant_of(self, queryset, name, part):
|
||||
"""Limit queryset to direct children (variants) of the specified part"""
|
||||
|
||||
queryset = queryset.filter(id__in=part.get_children())
|
||||
return queryset
|
||||
|
||||
in_bom_for = rest_filters.ModelChoiceFilter(label='In BOM Of', queryset=Part.objects.all(), method='filter_in_bom')
|
||||
|
||||
def filter_in_bom(self, queryset, name, part):
|
||||
"""Limit queryset to parts in the BOM for the specified part"""
|
||||
|
||||
queryset = queryset.filter(id__in=part.get_parts_in_bom())
|
||||
return queryset
|
||||
|
||||
is_template = rest_filters.BooleanFilter()
|
||||
|
||||
assembly = rest_filters.BooleanFilter()
|
||||
@ -1129,61 +1176,6 @@ class PartList(APIDownloadMixin, generics.ListCreateAPIView):
|
||||
|
||||
queryset = queryset.exclude(pk__in=id_values)
|
||||
|
||||
# Exclude part variant tree?
|
||||
exclude_tree = params.get('exclude_tree', None)
|
||||
|
||||
if exclude_tree is not None:
|
||||
try:
|
||||
top_level_part = Part.objects.get(pk=exclude_tree)
|
||||
|
||||
queryset = queryset.exclude(
|
||||
pk__in=[prt.pk for prt in top_level_part.get_descendants(include_self=True)]
|
||||
)
|
||||
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Filter by 'ancestor'?
|
||||
ancestor = params.get('ancestor', None)
|
||||
|
||||
if ancestor is not None:
|
||||
# If an 'ancestor' part is provided, filter to match only children
|
||||
try:
|
||||
ancestor = Part.objects.get(pk=ancestor)
|
||||
descendants = ancestor.get_descendants(include_self=False)
|
||||
queryset = queryset.filter(pk__in=[d.pk for d in descendants])
|
||||
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)
|
||||
|
||||
if in_bom_for is not None:
|
||||
try:
|
||||
in_bom_for = Part.objects.get(pk=in_bom_for)
|
||||
|
||||
# Extract a list of parts within the BOM
|
||||
bom_parts = in_bom_for.get_parts_in_bom()
|
||||
print("bom_parts:", bom_parts)
|
||||
print([p.pk for p in bom_parts])
|
||||
|
||||
queryset = queryset.filter(pk__in=[p.pk for p in bom_parts])
|
||||
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Filter by whether the BOM has been validated (or not)
|
||||
bom_valid = params.get('bom_valid', None)
|
||||
|
||||
|
@ -391,6 +391,64 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
response = self.get(url, {'related': 1}, expected_code=200)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
def test_filter_by_convert(self):
|
||||
"""Test that we can correctly filter the Part list by conversion options"""
|
||||
|
||||
category = PartCategory.objects.get(pk=3)
|
||||
|
||||
# First, construct a set of template / variant parts
|
||||
master_part = Part.objects.create(
|
||||
name='Master', description='Master part',
|
||||
category=category,
|
||||
is_template=True,
|
||||
)
|
||||
|
||||
# Construct a set of variant parts
|
||||
variants = []
|
||||
|
||||
for color in ['Red', 'Green', 'Blue', 'Yellow', 'Pink', 'Black']:
|
||||
variants.append(Part.objects.create(
|
||||
name=f"{color} Variant", description="Variant part with a specific color",
|
||||
variant_of=master_part,
|
||||
category=category,
|
||||
))
|
||||
|
||||
url = reverse('api-part-list')
|
||||
|
||||
# An invalid part ID will return an error
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'convert_from': 999999,
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('Select a valid choice', str(response.data['convert_from']))
|
||||
|
||||
for variant in variants:
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'convert_from': variant.pk,
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
# There should be the same number of results for each request
|
||||
self.assertEqual(len(response.data), 6)
|
||||
|
||||
id_values = [p['pk'] for p in response.data]
|
||||
|
||||
self.assertIn(master_part.pk, id_values)
|
||||
|
||||
for v in variants:
|
||||
# Check that all *other* variants are included also
|
||||
if v == variant:
|
||||
continue
|
||||
|
||||
self.assertIn(v.pk, id_values)
|
||||
|
||||
def test_include_children(self):
|
||||
"""Test the special 'include_child_categories' flag.
|
||||
|
||||
|
@ -129,6 +129,12 @@ class StockItemUninstall(StockItemContextMixin, generics.CreateAPIView):
|
||||
serializer_class = StockSerializers.UninstallStockItemSerializer
|
||||
|
||||
|
||||
class StockItemConvert(StockItemContextMixin, generics.CreateAPIView):
|
||||
"""API endpoint for converting a stock item to a variant part"""
|
||||
|
||||
serializer_class = StockSerializers.ConvertStockItemSerializer
|
||||
|
||||
|
||||
class StockItemReturn(StockItemContextMixin, generics.CreateAPIView):
|
||||
"""API endpoint for returning a stock item from a customer"""
|
||||
|
||||
@ -1374,6 +1380,7 @@ stock_api_urls = [
|
||||
|
||||
# Detail views for a single stock item
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'^convert/', StockItemConvert.as_view(), name='api-stock-item-convert'),
|
||||
re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'),
|
||||
re_path(r'^metadata/', StockMetadata.as_view(), name='api-stock-item-metadata'),
|
||||
re_path(r'^return/', StockItemReturn.as_view(), name='api-stock-item-return'),
|
||||
|
@ -1,20 +0,0 @@
|
||||
"""Django Forms for interacting with Stock app."""
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
|
||||
from .models import StockItem
|
||||
|
||||
|
||||
class ConvertStockItemForm(HelperForm):
|
||||
"""Form for converting a StockItem to a variant of its current part.
|
||||
|
||||
TODO: Migrate this form to the modern API forms interface
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = StockItem
|
||||
fields = [
|
||||
'part'
|
||||
]
|
@ -17,6 +17,7 @@ import common.models
|
||||
import company.models
|
||||
import InvenTree.helpers
|
||||
import InvenTree.serializers
|
||||
import part.models as part_models
|
||||
from common.settings import currency_code_default, currency_code_mappings
|
||||
from company.serializers import SupplierPartSerializer
|
||||
from InvenTree.serializers import InvenTreeDecimalField, extract_int
|
||||
@ -464,6 +465,45 @@ class UninstallStockItemSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class ConvertStockItemSerializer(serializers.Serializer):
|
||||
"""DRF serializer class for converting a StockItem to a valid variant part"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
fields = [
|
||||
'part',
|
||||
]
|
||||
|
||||
part = serializers.PrimaryKeyRelatedField(
|
||||
queryset=part_models.Part.objects.all(),
|
||||
label=_('Part'),
|
||||
help_text=_('Select part to convert stock item into'),
|
||||
many=False, required=True, allow_null=False
|
||||
)
|
||||
|
||||
def validate_part(self, part):
|
||||
"""Ensure that the provided part is a valid option for the stock item"""
|
||||
|
||||
stock_item = self.context['item']
|
||||
valid_options = stock_item.part.get_conversion_options()
|
||||
|
||||
if part not in valid_options:
|
||||
raise ValidationError(_("Selected part is not a valid option for conversion"))
|
||||
|
||||
return part
|
||||
|
||||
def save(self):
|
||||
"""Save the serializer to convert the StockItem to the selected Part"""
|
||||
data = self.validated_data
|
||||
|
||||
part = data['part']
|
||||
|
||||
stock_item = self.context['item']
|
||||
request = self.context['request']
|
||||
|
||||
stock_item.convert_to_variant(part, request.user)
|
||||
|
||||
|
||||
class ReturnStockItemSerializer(serializers.Serializer):
|
||||
"""DRF serializer for returning a stock item from a customer"""
|
||||
|
||||
|
@ -588,9 +588,31 @@ $("#stock-delete").click(function () {
|
||||
|
||||
{% if item.part.can_convert %}
|
||||
$("#stock-convert").click(function() {
|
||||
launchModalForm("{% url 'stock-item-convert' item.id %}",
|
||||
|
||||
var html = `
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% trans "Select one of the part variants listed below." %}
|
||||
</div>
|
||||
<div class='alert alert-block alert-warning'>
|
||||
<strong>{% trans "Warning" %}</strong>
|
||||
{% trans "This action cannot be easily undone" %}
|
||||
</div>
|
||||
`;
|
||||
|
||||
constructForm(
|
||||
'{% url "api-stock-item-convert" item.pk %}',
|
||||
{
|
||||
method: 'POST',
|
||||
title: '{% trans "Convert Stock Item" %}',
|
||||
preFormContent: html,
|
||||
reload: true,
|
||||
fields: {
|
||||
part: {
|
||||
filters: {
|
||||
convert_from: {{ item.part.pk }}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -1,17 +0,0 @@
|
||||
{% extends "modal_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
<div class='alert alert-block alert-info'>
|
||||
<strong>{% trans "Convert Stock Item" %}</strong><br>
|
||||
{% blocktrans with part=item.part %}This stock item is current an instance of <em>{{part}}</em>{% endblocktrans %}<br>
|
||||
{% trans "It can be converted to one of the part variants listed below." %}
|
||||
</div>
|
||||
|
||||
<div class='alert alert-block alert-warning'>
|
||||
<strong>{% trans "Warning" %}</strong>
|
||||
{% trans "This action cannot be easily undone" %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
@ -702,6 +702,69 @@ class StockItemTest(StockAPITestCase):
|
||||
# The item is now in stock
|
||||
self.assertIsNone(item.customer)
|
||||
|
||||
def test_convert_to_variant(self):
|
||||
"""Test that we can convert a StockItem to a variant part via the API"""
|
||||
|
||||
category = part.models.PartCategory.objects.get(pk=3)
|
||||
|
||||
# First, construct a set of template / variant parts
|
||||
master_part = part.models.Part.objects.create(
|
||||
name='Master', description='Master part',
|
||||
category=category,
|
||||
is_template=True,
|
||||
)
|
||||
|
||||
variants = []
|
||||
|
||||
# Construct a set of variant parts
|
||||
for color in ['Red', 'Green', 'Blue', 'Yellow', 'Pink', 'Black']:
|
||||
variants.append(part.models.Part.objects.create(
|
||||
name=f"{color} Variant", description="Variant part with a specific color",
|
||||
variant_of=master_part,
|
||||
category=category,
|
||||
))
|
||||
|
||||
stock_item = StockItem.objects.create(
|
||||
part=master_part,
|
||||
quantity=1000,
|
||||
)
|
||||
|
||||
url = reverse('api-stock-item-convert', kwargs={'pk': stock_item.pk})
|
||||
|
||||
# Attempt to convert to a part which does not exist
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'part': 999999,
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('object does not exist', str(response.data['part']))
|
||||
|
||||
# Attempt to convert to a part which is not a valid option
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'part': 1,
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('Selected part is not a valid option', str(response.data['part']))
|
||||
|
||||
for variant in variants:
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'part': variant.pk,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
stock_item.refresh_from_db()
|
||||
self.assertEqual(stock_item.part, variant)
|
||||
|
||||
|
||||
class StocktakeTest(StockAPITestCase):
|
||||
"""Series of tests for the Stocktake API."""
|
||||
|
@ -16,7 +16,6 @@ location_urls = [
|
||||
]
|
||||
|
||||
stock_item_detail_urls = [
|
||||
re_path(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'),
|
||||
re_path(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'),
|
||||
|
||||
# Anything else - direct to the item detail view
|
||||
|
@ -6,10 +6,9 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
|
||||
import common.settings
|
||||
from InvenTree.views import AjaxUpdateView, InvenTreeRoleMixin, QRCodeView
|
||||
from InvenTree.views import InvenTreeRoleMixin, QRCodeView
|
||||
from plugin.views import InvenTreePluginViewMixin
|
||||
|
||||
from . import forms as StockForms
|
||||
from .models import StockItem, StockLocation
|
||||
|
||||
|
||||
@ -133,32 +132,3 @@ class StockItemQRCode(QRCodeView):
|
||||
return item.format_barcode()
|
||||
except StockItem.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class StockItemConvert(AjaxUpdateView):
|
||||
"""View for 'converting' a StockItem to a variant of its current part."""
|
||||
|
||||
model = StockItem
|
||||
form_class = StockForms.ConvertStockItemForm
|
||||
ajax_form_title = _('Convert Stock Item')
|
||||
ajax_template_name = 'stock/stockitem_convert.html'
|
||||
context_object_name = 'item'
|
||||
|
||||
def get_form(self):
|
||||
"""Filter the available parts."""
|
||||
form = super().get_form()
|
||||
item = self.get_object()
|
||||
|
||||
form.fields['part'].queryset = item.part.get_conversion_options()
|
||||
|
||||
return form
|
||||
|
||||
def save(self, obj, form):
|
||||
"""Convert item to variant."""
|
||||
stock_item = self.get_object()
|
||||
|
||||
variant = form.cleaned_data.get('part', None)
|
||||
|
||||
stock_item.convert_to_variant(variant, user=self.request.user)
|
||||
|
||||
return stock_item
|
||||
|
Loading…
Reference in New Issue
Block a user