mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 07:05:41 +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:
		@@ -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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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'),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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 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"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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 }}
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
					        # 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."""
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user