diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 2daa9e3da75b..b7ce60949112 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 23b2955744bd..7f9b0d150d1f 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -810,6 +810,53 @@ def filter_unallocated_stock(self, queryset, name, value): 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 @@ def filter_queryset(self, queryset): 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) diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 1c8aa694d33a..3d2c0d8a0652 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -391,6 +391,64 @@ def test_filter_by_related(self): 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. diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 13b3c0219e6a..e3fe14d6b4d0 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -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 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView): # Detail views for a single stock item re_path(r'^(?P\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'), diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py deleted file mode 100644 index e091730debc4..000000000000 --- a/InvenTree/stock/forms.py +++ /dev/null @@ -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' - ] diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index aebc102e79e2..a5a39ab146d5 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -17,6 +17,7 @@ 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 @@ def save(self): ) +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""" diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 19964e8b291e..ff8c7687b74c 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -588,9 +588,31 @@
{% if item.quantity != available %}{% decimal available %} / {% endif %}{% d {% if item.part.can_convert %} $("#stock-convert").click(function() { - launchModalForm("{% url 'stock-item-convert' item.id %}", + + var html = ` +
+ {% trans "Select one of the part variants listed below." %} +
+
+ {% trans "Warning" %} + {% trans "This action cannot be easily undone" %} +
+ `; + + 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 }} + } + }, + } } ); }); diff --git a/InvenTree/stock/templates/stock/stockitem_convert.html b/InvenTree/stock/templates/stock/stockitem_convert.html deleted file mode 100644 index 90c3fd8e1e72..000000000000 --- a/InvenTree/stock/templates/stock/stockitem_convert.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} - -
- {% trans "Convert Stock Item" %}
- {% blocktrans with part=item.part %}This stock item is current an instance of {{part}}{% endblocktrans %}
- {% trans "It can be converted to one of the part variants listed below." %} -
- -
- {% trans "Warning" %} - {% trans "This action cannot be easily undone" %} -
- -{% endblock %} diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 93f35b78e1bb..e35eabc7df7f 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -702,6 +702,69 @@ def test_return_from_customer(self): # 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.""" diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 6c4eec8d7e18..b61bd8eb6045 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -16,7 +16,6 @@ ] 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 diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 267b8734d196..6177972259f2 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -6,10 +6,9 @@ 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 @@ def get_qr_data(self): 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