2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 03:26:45 +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:
Oliver 2022-06-12 16:06:11 +10:00 committed by GitHub
parent 9b86bc6002
commit 8b464e4397
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 244 additions and 126 deletions

View File

@ -2,11 +2,15 @@
# InvenTree API version # 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 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 v60 -> 2022-06-08 : https://github.com/inventree/InvenTree/pull/3148
- Add availability data fields to the SupplierPart model - Add availability data fields to the SupplierPart model

View File

@ -810,6 +810,53 @@ class PartFilter(rest_filters.FilterSet):
return queryset 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() is_template = rest_filters.BooleanFilter()
assembly = rest_filters.BooleanFilter() assembly = rest_filters.BooleanFilter()
@ -1129,61 +1176,6 @@ class PartList(APIDownloadMixin, generics.ListCreateAPIView):
queryset = queryset.exclude(pk__in=id_values) 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) # Filter by whether the BOM has been validated (or not)
bom_valid = params.get('bom_valid', None) bom_valid = params.get('bom_valid', None)

View File

@ -391,6 +391,64 @@ class PartAPITest(InvenTreeAPITestCase):
response = self.get(url, {'related': 1}, expected_code=200) response = self.get(url, {'related': 1}, expected_code=200)
self.assertEqual(len(response.data), 2) 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): def test_include_children(self):
"""Test the special 'include_child_categories' flag. """Test the special 'include_child_categories' flag.

View File

@ -129,6 +129,12 @@ class StockItemUninstall(StockItemContextMixin, generics.CreateAPIView):
serializer_class = StockSerializers.UninstallStockItemSerializer 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): class StockItemReturn(StockItemContextMixin, generics.CreateAPIView):
"""API endpoint for returning a stock item from a customer""" """API endpoint for returning a stock item from a customer"""
@ -1374,6 +1380,7 @@ stock_api_urls = [
# Detail views for a single stock item # Detail views for a single stock item
re_path(r'^(?P<pk>\d+)/', include([ 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'^install/', StockItemInstall.as_view(), name='api-stock-item-install'),
re_path(r'^metadata/', StockMetadata.as_view(), name='api-stock-item-metadata'), re_path(r'^metadata/', StockMetadata.as_view(), name='api-stock-item-metadata'),
re_path(r'^return/', StockItemReturn.as_view(), name='api-stock-item-return'), re_path(r'^return/', StockItemReturn.as_view(), name='api-stock-item-return'),

View File

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

View File

@ -17,6 +17,7 @@ import common.models
import company.models import company.models
import InvenTree.helpers import InvenTree.helpers
import InvenTree.serializers import InvenTree.serializers
import part.models as part_models
from common.settings import currency_code_default, currency_code_mappings from common.settings import currency_code_default, currency_code_mappings
from company.serializers import SupplierPartSerializer from company.serializers import SupplierPartSerializer
from InvenTree.serializers import InvenTreeDecimalField, extract_int 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): class ReturnStockItemSerializer(serializers.Serializer):
"""DRF serializer for returning a stock item from a customer""" """DRF serializer for returning a stock item from a customer"""

View File

@ -588,9 +588,31 @@ $("#stock-delete").click(function () {
{% if item.part.can_convert %} {% if item.part.can_convert %}
$("#stock-convert").click(function() { $("#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, reload: true,
fields: {
part: {
filters: {
convert_from: {{ item.part.pk }}
}
},
}
} }
); );
}); });

View File

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

View File

@ -702,6 +702,69 @@ class StockItemTest(StockAPITestCase):
# The item is now in stock # The item is now in stock
self.assertIsNone(item.customer) 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): class StocktakeTest(StockAPITestCase):
"""Series of tests for the Stocktake API.""" """Series of tests for the Stocktake API."""

View File

@ -16,7 +16,6 @@ location_urls = [
] ]
stock_item_detail_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'), re_path(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'),
# Anything else - direct to the item detail view # Anything else - direct to the item detail view

View File

@ -6,10 +6,9 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
import common.settings import common.settings
from InvenTree.views import AjaxUpdateView, InvenTreeRoleMixin, QRCodeView from InvenTree.views import InvenTreeRoleMixin, QRCodeView
from plugin.views import InvenTreePluginViewMixin from plugin.views import InvenTreePluginViewMixin
from . import forms as StockForms
from .models import StockItem, StockLocation from .models import StockItem, StockLocation
@ -133,32 +132,3 @@ class StockItemQRCode(QRCodeView):
return item.format_barcode() return item.format_barcode()
except StockItem.DoesNotExist: except StockItem.DoesNotExist:
return None 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