2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-01 11:10:54 +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
11 changed files with 244 additions and 126 deletions

View File

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

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

View File

@ -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 }}
}
},
}
}
);
});

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

View File

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

View File

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