Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate "Convert to Variant" form to the API #3183

Merged
6 changes: 5 additions & 1 deletion InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
102 changes: 47 additions & 55 deletions InvenTree/part/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Expand Down
58 changes: 58 additions & 0 deletions InvenTree/part/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 7 additions & 0 deletions InvenTree/stock/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand Down Expand Up @@ -1374,6 +1380,7 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView):

# 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'),
Expand Down
20 changes: 0 additions & 20 deletions InvenTree/stock/forms.py

This file was deleted.

40 changes: 40 additions & 0 deletions InvenTree/stock/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"""

Expand Down
24 changes: 23 additions & 1 deletion InvenTree/stock/templates/stock/item_base.html
Original file line number Diff line number Diff line change
Expand Up @@ -588,9 +588,31 @@ <h5>{% 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 = `
<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 }}
}
},
}
}
);
});
Expand Down
17 changes: 0 additions & 17 deletions InvenTree/stock/templates/stock/stockitem_convert.html

This file was deleted.

Loading