diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 71e518560b..eca502425a 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -455,6 +455,10 @@ -webkit-opacity: 10%; } +.table-condensed { + font-size: 90%; +} + /* grid display for part images */ .table-img-grid tr { diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index cc897d6ec9..7920003d8b 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -21,7 +21,7 @@ from InvenTree.status_codes import BuildStatus from .models import Build, BuildItem, BuildOrderAttachment from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer -from .serializers import BuildAllocationSerializer +from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer class BuildFilter(rest_filters.FilterSet): @@ -184,6 +184,42 @@ class BuildDetail(generics.RetrieveUpdateAPIView): serializer_class = BuildSerializer +class BuildUnallocate(generics.CreateAPIView): + """ + API endpoint for unallocating stock items from a build order + + - The BuildOrder object is specified by the URL + - "output" (StockItem) can optionally be specified + - "bom_item" can optionally be specified + """ + + queryset = Build.objects.none() + + serializer_class = BuildUnallocationSerializer + + def get_build(self): + """ + Returns the BuildOrder associated with this API endpoint + """ + + pk = self.kwargs.get('pk', None) + + try: + build = Build.objects.get(pk=pk) + except (ValueError, Build.DoesNotExist): + raise ValidationError(_("Matching build order does not exist")) + + return build + + def get_serializer_context(self): + + ctx = super().get_serializer_context() + ctx['build'] = self.get_build() + ctx['request'] = self.request + + return ctx + + class BuildAllocate(generics.CreateAPIView): """ API endpoint to allocate stock items to a build order @@ -349,6 +385,7 @@ build_api_urls = [ # Build Detail url(r'^(?P\d+)/', include([ url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), + url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'), ])), diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index b3f6cd92de..bc7bdd50f5 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -137,32 +137,6 @@ class BuildOutputDeleteForm(HelperForm): ] -class UnallocateBuildForm(HelperForm): - """ - Form for auto-de-allocation of stock from a build - """ - - confirm = forms.BooleanField(required=False, label=_('Confirm'), help_text=_('Confirm unallocation of stock')) - - output_id = forms.IntegerField( - required=False, - widget=forms.HiddenInput() - ) - - part_id = forms.IntegerField( - required=False, - widget=forms.HiddenInput(), - ) - - class Meta: - model = Build - fields = [ - 'confirm', - 'output_id', - 'part_id', - ] - - class CompleteBuildForm(HelperForm): """ Form for marking a build as complete diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 9a7b40b52f..449776579e 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -587,9 +587,13 @@ class Build(MPTTModel): self.save() @transaction.atomic - def unallocateOutput(self, output, part=None): + def unallocateStock(self, bom_item=None, output=None): """ - Unallocate all stock which are allocated against the provided "output" (StockItem) + Unallocate stock from this Build + + arguments: + - bom_item: Specify a particular BomItem to unallocate stock against + - output: Specify a particular StockItem (output) to unallocate stock against """ allocations = BuildItem.objects.filter( @@ -597,34 +601,8 @@ class Build(MPTTModel): install_into=output ) - if part: - allocations = allocations.filter(stock_item__part=part) - - allocations.delete() - - @transaction.atomic - def unallocateUntracked(self, part=None): - """ - Unallocate all "untracked" stock - """ - - allocations = BuildItem.objects.filter( - build=self, - install_into=None - ) - - if part: - allocations = allocations.filter(stock_item__part=part) - - allocations.delete() - - @transaction.atomic - def unallocateAll(self): - """ - Deletes all stock allocations for this build. - """ - - allocations = BuildItem.objects.filter(build=self) + if bom_item: + allocations = allocations.filter(bom_item=bom_item) allocations.delete() @@ -720,7 +698,7 @@ class Build(MPTTModel): raise ValidationError(_("Build output does not match Build Order")) # Unallocate all build items against the output - self.unallocateOutput(output) + self.unallocateStock(output=output) # Remove the build output from the database output.delete() @@ -1153,16 +1131,12 @@ class BuildItem(models.Model): i) The sub_part points to the same part as the referenced StockItem ii) The BomItem allows variants and the part referenced by the StockItem is a variant of the sub_part referenced by the BomItem + iii) The Part referenced by the StockItem is a valid substitute for the BomItem """ if self.build and self.build.part == self.bom_item.part: - # Check that the sub_part points to the stock_item (either directly or via a variant) - if self.bom_item.sub_part == self.stock_item.part: - bom_item_valid = True - - elif self.bom_item.allow_variants and self.stock_item.part in self.bom_item.sub_part.get_descendants(include_self=False): - bom_item_valid = True + bom_item_valid = self.bom_item.is_stock_item_valid(self.stock_item) # If the existing BomItem is *not* valid, try to find a match if not bom_item_valid: diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 53e71dbd27..547f565905 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -120,6 +120,61 @@ class BuildSerializer(InvenTreeModelSerializer): ] +class BuildUnallocationSerializer(serializers.Serializer): + """ + DRF serializer for unallocating stock from a BuildOrder + + Allocated stock can be unallocated with a number of filters: + + - output: Filter against a particular build output (blank = untracked stock) + - bom_item: Filter against a particular BOM line item + + """ + + bom_item = serializers.PrimaryKeyRelatedField( + queryset=BomItem.objects.all(), + many=False, + allow_null=True, + required=False, + label=_('BOM Item'), + ) + + output = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.filter( + is_building=True, + ), + many=False, + allow_null=True, + required=False, + label=_("Build output"), + ) + + def validate_output(self, stock_item): + + # Stock item must point to the same build order! + build = self.context['build'] + + if stock_item and stock_item.build != build: + raise ValidationError(_("Build output must point to the same build")) + + return stock_item + + def save(self): + """ + 'Save' the serializer data. + This performs the actual unallocation against the build order + """ + + build = self.context['build'] + + data = self.validated_data + + build.unallocateStock( + bom_item=data['bom_item'], + output=data['output'] + ) + + class BuildAllocationItemSerializer(serializers.Serializer): """ A serializer for allocating a single stock item against a build order diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 8fb259f8a4..cfba2046e3 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -197,7 +197,7 @@ -
+
@@ -462,12 +462,9 @@ $("#btn-auto-allocate").on('click', function() { }); $('#btn-unallocate').on('click', function() { - launchModalForm( - "{% url 'build-unallocate' build.id %}", - { - success: reloadTable, - } - ); + unallocateStock({{ build.id }}, { + table: '#allocation-table-untracked', + }); }); $('#allocate-selected-items').click(function() { diff --git a/InvenTree/build/templates/build/unallocate.html b/InvenTree/build/templates/build/unallocate.html deleted file mode 100644 index a650e95718..0000000000 --- a/InvenTree/build/templates/build/unallocate.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} -{% load inventree_extras %} -{% block pre_form_content %} - -{{ block.super }} - - -
- {% trans "Are you sure you wish to unallocate all stock for this build?" %} -
- {% trans "All incomplete stock allocations will be removed from the build" %} -
- -{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index 04b46bbd26..a0874d0979 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -250,7 +250,7 @@ class BuildTest(TestCase): self.assertEqual(len(unallocated), 1) - self.build.unallocateUntracked() + self.build.unallocateStock() unallocated = self.build.unallocatedParts(None) diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py index 93c6bfd511..7b2568b1c7 100644 --- a/InvenTree/build/tests.py +++ b/InvenTree/build/tests.py @@ -323,22 +323,3 @@ class TestBuildViews(TestCase): b = Build.objects.get(pk=1) self.assertEqual(b.status, 30) # Build status is now CANCELLED - - def test_build_unallocate(self): - """ Test the build unallocation view (ajax form) """ - - url = reverse('build-unallocate', args=(1,)) - - # Test without confirmation - response = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - data = json.loads(response.content) - self.assertFalse(data['form_valid']) - - # Test with confirmation - response = self.client.post(url, {'confirm': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - data = json.loads(response.content) - self.assertTrue(data['form_valid']) diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index 050c32209b..d80b16056c 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -12,7 +12,6 @@ build_detail_urls = [ url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'), url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'), url(r'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'), - url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'), url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'), url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 702b3b3596..8c63c1296c 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -10,14 +10,13 @@ from django.core.exceptions import ValidationError from django.views.generic import DetailView, ListView from django.forms import HiddenInput -from part.models import Part from .models import Build from . import forms from stock.models import StockLocation, StockItem from InvenTree.views import AjaxUpdateView, AjaxDeleteView from InvenTree.views import InvenTreeRoleMixin -from InvenTree.helpers import str2bool, extract_serial_numbers, isNull +from InvenTree.helpers import str2bool, extract_serial_numbers from InvenTree.status_codes import BuildStatus, StockStatus @@ -246,88 +245,6 @@ class BuildOutputDelete(AjaxUpdateView): } -class BuildUnallocate(AjaxUpdateView): - """ View to un-allocate all parts from a build. - - Provides a simple confirmation dialog with a BooleanField checkbox. - """ - - model = Build - form_class = forms.UnallocateBuildForm - ajax_form_title = _("Unallocate Stock") - ajax_template_name = "build/unallocate.html" - - def get_initial(self): - - initials = super().get_initial() - - # Pointing to a particular build output? - output = self.get_param('output') - - if output: - initials['output_id'] = output - - # Pointing to a particular part? - part = self.get_param('part') - - if part: - initials['part_id'] = part - - return initials - - def post(self, request, *args, **kwargs): - - build = self.get_object() - form = self.get_form() - - confirm = request.POST.get('confirm', False) - - output_id = request.POST.get('output_id', None) - - if output_id: - - # If a "null" output is provided, we are trying to unallocate "untracked" stock - if isNull(output_id): - output = None - else: - try: - output = StockItem.objects.get(pk=output_id) - except (ValueError, StockItem.DoesNotExist): - output = None - - part_id = request.POST.get('part_id', None) - - try: - part = Part.objects.get(pk=part_id) - except (ValueError, Part.DoesNotExist): - part = None - - valid = False - - if confirm is False: - form.add_error('confirm', _('Confirm unallocation of build stock')) - form.add_error(None, _('Check the confirmation box')) - else: - - valid = True - - # Unallocate the entire build - if not output_id: - build.unallocateAll() - # Unallocate a single output - elif output: - build.unallocateOutput(output, part=part) - # Unallocate "untracked" parts - else: - build.unallocateUntracked(part=part) - - data = { - 'form_valid': valid, - } - - return self.renderJsonResponse(request, form, data) - - class BuildComplete(AjaxUpdateView): """ View to mark the build as complete. diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index de37d4ea52..dccc2f9ac1 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -27,7 +27,8 @@ from djmoney.contrib.exchange.exceptions import MissingRate from decimal import Decimal, InvalidOperation -from .models import Part, PartCategory, BomItem +from .models import Part, PartCategory +from .models import BomItem, BomItemSubstitute from .models import PartParameter, PartParameterTemplate from .models import PartAttachment, PartTestTemplate from .models import PartSellPriceBreak, PartInternalPriceBreak @@ -1078,11 +1079,23 @@ class BomList(generics.ListCreateAPIView): queryset = self.filter_queryset(self.get_queryset()) - serializer = self.get_serializer(queryset, many=True) + page = self.paginate_queryset(queryset) + + if page is not None: + serializer = self.get_serializer(page, many=True) + else: + serializer = self.get_serializer(queryset, many=True) data = serializer.data - if request.is_ajax(): + """ + Determine the response type based on the request. + a) For HTTP requests (e.g. via the browseable API) return a DRF response + b) For AJAX requests, simply return a JSON rendered response. + """ + if page is not None: + return self.get_paginated_response(data) + elif request.is_ajax(): return JsonResponse(data, safe=False) else: return Response(data) @@ -1102,7 +1115,7 @@ class BomList(generics.ListCreateAPIView): try: # Include or exclude pricing information in the serialized data - kwargs['include_pricing'] = str2bool(self.request.GET.get('include_pricing', True)) + kwargs['include_pricing'] = self.include_pricing() except AttributeError: pass @@ -1147,13 +1160,19 @@ class BomList(generics.ListCreateAPIView): except (ValueError, Part.DoesNotExist): pass - include_pricing = str2bool(params.get('include_pricing', True)) - - if include_pricing: + if self.include_pricing(): queryset = self.annotate_pricing(queryset) return queryset + def include_pricing(self): + """ + Determine if pricing information should be included in the response + """ + pricing_default = InvenTreeSetting.get_setting('PART_SHOW_PRICE_IN_BOM') + + return str2bool(self.request.query_params.get('include_pricing', pricing_default)) + def annotate_pricing(self, queryset): """ Add part pricing information to the queryset @@ -1262,6 +1281,35 @@ class BomItemValidate(generics.UpdateAPIView): return Response(serializer.data) +class BomItemSubstituteList(generics.ListCreateAPIView): + """ + API endpoint for accessing a list of BomItemSubstitute objects + """ + + serializer_class = part_serializers.BomItemSubstituteSerializer + queryset = BomItemSubstitute.objects.all() + + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + + filter_fields = [ + 'part', + 'bom_item', + ] + + +class BomItemSubstituteDetail(generics.RetrieveUpdateDestroyAPIView): + """ + API endpoint for detail view of a single BomItemSubstitute object + """ + + queryset = BomItemSubstitute.objects.all() + serializer_class = part_serializers.BomItemSubstituteSerializer + + part_api_urls = [ url(r'^tree/?', PartCategoryTree.as_view(), name='api-part-tree'), @@ -1314,6 +1362,16 @@ part_api_urls = [ ] bom_api_urls = [ + + url(r'^substitute/', include([ + + # Detail view + url(r'^(?P\d+)/', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'), + + # Catch all + url(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'), + ])), + # BOM Item Detail url(r'^(?P\d+)/', include([ url(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'), diff --git a/InvenTree/part/migrations/0072_bomitemsubstitute.py b/InvenTree/part/migrations/0072_bomitemsubstitute.py new file mode 100644 index 0000000000..2e48b10a8d --- /dev/null +++ b/InvenTree/part/migrations/0072_bomitemsubstitute.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.5 on 2021-10-12 23:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0071_alter_partparametertemplate_name'), + ] + + operations = [ + migrations.CreateModel( + name='BomItemSubstitute', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('bom_item', models.ForeignKey(help_text='Parent BOM item', on_delete=django.db.models.deletion.CASCADE, related_name='substitutes', to='part.bomitem', verbose_name='BOM Item')), + ('part', models.ForeignKey(help_text='Substitute part', limit_choices_to={'component': True}, on_delete=django.db.models.deletion.CASCADE, related_name='substitute_items', to='part.part', verbose_name='Part')), + ], + ), + ] diff --git a/InvenTree/part/migrations/0073_auto_20211013_1048.py b/InvenTree/part/migrations/0073_auto_20211013_1048.py new file mode 100644 index 0000000000..e581af603e --- /dev/null +++ b/InvenTree/part/migrations/0073_auto_20211013_1048.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.5 on 2021-10-13 10:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0072_bomitemsubstitute'), + ] + + operations = [ + migrations.AlterModelOptions( + name='bomitemsubstitute', + options={'verbose_name': 'BOM Item Substitute'}, + ), + migrations.AlterUniqueTogether( + name='bomitemsubstitute', + unique_together={('part', 'bom_item')}, + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 8c43a623a0..fe3e017b28 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2333,22 +2333,48 @@ class BomItem(models.Model): def get_api_url(): return reverse('api-bom-list') + def get_valid_parts_for_allocation(self): + """ + Return a list of valid parts which can be allocated against this BomItem: + + - Include the referenced sub_part + - Include any directly specvified substitute parts + - If allow_variants is True, allow all variants of sub_part + """ + + # Set of parts we will allow + parts = set() + + parts.add(self.sub_part) + + # Variant parts (if allowed) + if self.allow_variants: + for variant in self.sub_part.get_descendants(include_self=False): + parts.add(variant) + + # Substitute parts + for sub in self.substitutes.all(): + parts.add(sub.part) + + return parts + + def is_stock_item_valid(self, stock_item): + """ + Check if the provided StockItem object is "valid" for assignment against this BomItem + """ + + return stock_item.part in self.get_valid_parts_for_allocation() + def get_stock_filter(self): """ Return a queryset filter for selecting StockItems which match this BomItem + - Allow stock from all directly specified substitute parts - If allow_variants is True, allow all part variants """ - # Target part - part = self.sub_part - - if self.allow_variants: - variants = part.get_descendants(include_self=True) - return Q(part__in=[v.pk for v in variants]) - else: - return Q(part=part) + return Q(part__in=[part.pk for part in self.get_valid_parts_for_allocation()]) def save(self, *args, **kwargs): @@ -2613,6 +2639,66 @@ class BomItem(models.Model): return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax) +class BomItemSubstitute(models.Model): + """ + A BomItemSubstitute provides a specification for alternative parts, + which can be used in a bill of materials. + + Attributes: + bom_item: Link to the parent BomItem instance + part: The part which can be used as a substitute + """ + + class Meta: + verbose_name = _("BOM Item Substitute") + + # Prevent duplication of substitute parts + unique_together = ('part', 'bom_item') + + def save(self, *args, **kwargs): + + self.full_clean() + + super().save(*args, **kwargs) + + def validate_unique(self, exclude=None): + """ + Ensure that this BomItemSubstitute is "unique": + + - It cannot point to the same "part" as the "sub_part" of the parent "bom_item" + """ + + super().validate_unique(exclude=exclude) + + if self.part == self.bom_item.sub_part: + raise ValidationError({ + "part": _("Substitute part cannot be the same as the master part"), + }) + + @staticmethod + def get_api_url(): + return reverse('api-bom-substitute-list') + + bom_item = models.ForeignKey( + BomItem, + on_delete=models.CASCADE, + related_name='substitutes', + verbose_name=_('BOM Item'), + help_text=_('Parent BOM item'), + ) + + part = models.ForeignKey( + Part, + on_delete=models.CASCADE, + related_name='substitute_items', + verbose_name=_('Part'), + help_text=_('Substitute part'), + limit_choices_to={ + 'component': True, + } + ) + + class PartRelated(models.Model): """ Store and handle related parts (eg. mating connector, crimps, etc.) """ diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 509de43b68..869145e1af 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -23,7 +23,8 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializerField, from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus from stock.models import StockItem -from .models import (BomItem, Part, PartAttachment, PartCategory, +from .models import (BomItem, BomItemSubstitute, + Part, PartAttachment, PartCategory, PartParameter, PartParameterTemplate, PartSellPriceBreak, PartStar, PartTestTemplate, PartCategoryParameterTemplate, PartInternalPriceBreak) @@ -388,8 +389,27 @@ class PartStarSerializer(InvenTreeModelSerializer): ] +class BomItemSubstituteSerializer(InvenTreeModelSerializer): + """ + Serializer for the BomItemSubstitute class + """ + + part_detail = PartBriefSerializer(source='part', read_only=True, many=False) + + class Meta: + model = BomItemSubstitute + fields = [ + 'pk', + 'bom_item', + 'part', + 'part_detail', + ] + + class BomItemSerializer(InvenTreeModelSerializer): - """ Serializer for BomItem object """ + """ + Serializer for BomItem object + """ price_range = serializers.CharField(read_only=True) @@ -397,6 +417,8 @@ class BomItemSerializer(InvenTreeModelSerializer): part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True)) + substitutes = BomItemSubstituteSerializer(many=True, read_only=True) + part_detail = PartBriefSerializer(source='part', many=False, read_only=True) sub_part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(component=True)) @@ -515,6 +537,7 @@ class BomItemSerializer(InvenTreeModelSerializer): 'reference', 'sub_part', 'sub_part_detail', + 'substitutes', 'price_range', 'validated', ] diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html index 750e205e04..988f65cfdb 100644 --- a/InvenTree/part/templates/part/bom.html +++ b/InvenTree/part/templates/part/bom.html @@ -1,11 +1,12 @@ {% load i18n %} {% load inventree_extras %} -{% if roles.part.change != True and editing_enabled %} +{% if not roles.part.change %}
{% trans "You do not have permission to edit the BOM." %}
-{% else %} +{% endif %} + {% if part.bom_checked_date %} {% if part.is_bom_valid %}
@@ -23,42 +24,38 @@
- {% if editing_enabled %} - - - {% if part.variant_of %} - - {% endif %} - - - {% elif part.active %} + + {% if roles.part.change %} - - {% if part.is_bom_valid == False %} - + +
+ {% endif %} - {% endif %} - {% endif %} - - +
@@ -67,4 +64,3 @@
-{% endif %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 80762e437a..419ed74b1a 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -473,7 +473,11 @@ onPanelLoad("bom", function() { // Load the BOM table data loadBomTable($("#bom-table"), { - editable: {{ editing_enabled }}, + {% if roles.part.change %} + editable: true, + {% else %} + editable: false, + {% endif %} bom_url: "{% url 'api-bom-list' %}", part_url: "{% url 'api-part-list' %}", parent_id: {{ part.id }} , @@ -486,11 +490,6 @@ ] ); - {% if editing_enabled %} - $("#editing-finished").click(function() { - location.href = "{% url 'part-detail' part.id %}?display=bom"; - }); - $('#bom-item-delete').click(function() { // Get a list of the selected BOM items @@ -559,8 +558,6 @@ }); }); - {% else %} - $("#validate-bom").click(function() { launchModalForm( "{% url 'bom-validate' part.id %}", @@ -570,10 +567,6 @@ ); }); - $("#edit-bom").click(function () { - location.href = "{% url 'part-detail' part.id %}?display=bom&edit=1"; - }); - $("#download-bom").click(function () { launchModalForm("{% url 'bom-export' part.id %}", { @@ -584,8 +577,6 @@ ); }); - {% endif %} - $("#print-bom-report").click(function() { printBomReports([{{ part.pk }}]); }); @@ -629,10 +620,9 @@ }); }); - // Load the BOM table data in the pricing view loadBomTable($("#bom-pricing-table"), { - editable: {{ editing_enabled }}, + editable: false, bom_url: "{% url 'api-bom-list' %}", part_url: "{% url 'api-part-list' %}", parent_id: {{ part.id }} , diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index ac9d6bdf45..ec377bd513 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from __future__ import unicode_literals import PIL @@ -11,7 +12,8 @@ from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.status_codes import StockStatus from part.models import Part, PartCategory -from stock.models import StockItem +from part.models import BomItem, BomItemSubstitute +from stock.models import StockItem, StockLocation from company.models import Company from common.models import InvenTreeSetting @@ -273,53 +275,6 @@ class PartAPITest(InvenTreeAPITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 3) - def test_get_bom_list(self): - """ There should be 4 BomItem objects in the database """ - url = reverse('api-bom-list') - response = self.client.get(url, format='json') - self.assertEqual(len(response.data), 5) - - def test_get_bom_detail(self): - # Get the detail for a single BomItem - url = reverse('api-bom-item-detail', kwargs={'pk': 3}) - response = self.client.get(url, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(int(float(response.data['quantity'])), 25) - - # Increase the quantity - data = response.data - data['quantity'] = 57 - data['note'] = 'Added a note' - - response = self.client.patch(url, data, format='json') - - # Check that the quantity was increased and a note added - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(int(float(response.data['quantity'])), 57) - self.assertEqual(response.data['note'], 'Added a note') - - def test_add_bom_item(self): - url = reverse('api-bom-list') - - data = { - 'part': 100, - 'sub_part': 4, - 'quantity': 777, - } - - response = self.client.post(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - # Now try to create a BomItem which points to a non-assembly part (should fail) - data['part'] = 3 - response = self.client.post(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - # TODO - Now try to create a BomItem which references itself - data['part'] = 2 - data['sub_part'] = 2 - response = self.client.post(url, data, format='json') - def test_test_templates(self): url = reverse('api-part-test-template-list') @@ -926,6 +881,249 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): self.assertEqual(data['stock_item_count'], 105) +class BomItemTest(InvenTreeAPITestCase): + """ + Unit tests for the BomItem API + """ + + fixtures = [ + 'category', + 'part', + 'location', + 'stock', + 'bom', + 'company', + ] + + roles = [ + 'part.add', + 'part.change', + 'part.delete', + ] + + def setUp(self): + super().setUp() + + def test_bom_list(self): + """ + Tests for the BomItem list endpoint + """ + + # How many BOM items currently exist in the database? + n = BomItem.objects.count() + + url = reverse('api-bom-list') + response = self.get(url, expected_code=200) + self.assertEqual(len(response.data), n) + + # Now, filter by part + response = self.get( + url, + data={ + 'part': 100, + }, + expected_code=200 + ) + + print("results:", len(response.data)) + + def test_get_bom_detail(self): + """ + Get the detail view for a single BomItem object + """ + + url = reverse('api-bom-item-detail', kwargs={'pk': 3}) + + response = self.get(url, expected_code=200) + + self.assertEqual(int(float(response.data['quantity'])), 25) + + # Increase the quantity + data = response.data + data['quantity'] = 57 + data['note'] = 'Added a note' + + response = self.patch(url, data, expected_code=200) + + self.assertEqual(int(float(response.data['quantity'])), 57) + self.assertEqual(response.data['note'], 'Added a note') + + def test_add_bom_item(self): + """ + Test that we can create a new BomItem via the API + """ + + url = reverse('api-bom-list') + + data = { + 'part': 100, + 'sub_part': 4, + 'quantity': 777, + } + + self.post(url, data, expected_code=201) + + # Now try to create a BomItem which references itself + data['part'] = 100 + data['sub_part'] = 100 + self.client.post(url, data, expected_code=400) + + def test_variants(self): + """ + Tests for BomItem use with variants + """ + + stock_url = reverse('api-stock-list') + + # BOM item we are interested in + bom_item = BomItem.objects.get(pk=1) + + bom_item.allow_variants = True + bom_item.save() + + # sub part that the BOM item points to + sub_part = bom_item.sub_part + + sub_part.is_template = True + sub_part.save() + + # How many stock items are initially available for this part? + response = self.get( + stock_url, + { + 'bom_item': bom_item.pk, + }, + expected_code=200 + ) + + n_items = len(response.data) + self.assertEqual(n_items, 2) + + loc = StockLocation.objects.get(pk=1) + + # Now we will create some variant parts and stock + for ii in range(5): + + # Create a variant part! + variant = Part.objects.create( + name=f"Variant_{ii}", + description="A variant part", + component=True, + variant_of=sub_part + ) + + variant.save() + + Part.objects.rebuild() + + # Create some stock items for this new part + for jj in range(ii): + StockItem.objects.create( + part=variant, + location=loc, + quantity=100 + ) + + # Keep track of running total + n_items += ii + + # Now, there should be more stock items available! + response = self.get( + stock_url, + { + 'bom_item': bom_item.pk, + }, + expected_code=200 + ) + + self.assertEqual(len(response.data), n_items) + + # Now, disallow variant parts in the BomItem + bom_item.allow_variants = False + bom_item.save() + + # There should now only be 2 stock items available again + response = self.get( + stock_url, + { + 'bom_item': bom_item.pk, + }, + expected_code=200 + ) + + self.assertEqual(len(response.data), 2) + + def test_substitutes(self): + """ + Tests for BomItem substitutes + """ + + url = reverse('api-bom-substitute-list') + stock_url = reverse('api-stock-list') + + # Initially we have no substitute parts + response = self.get(url, expected_code=200) + self.assertEqual(len(response.data), 0) + + # BOM item we are interested in + bom_item = BomItem.objects.get(pk=1) + + # Filter stock items which can be assigned against this stock item + response = self.get( + stock_url, + { + "bom_item": bom_item.pk, + }, + expected_code=200 + ) + + n_items = len(response.data) + + loc = StockLocation.objects.get(pk=1) + + # Let's make some! + for ii in range(5): + sub_part = Part.objects.create( + name=f"Substitute {ii}", + description="A substitute part", + component=True, + is_template=False, + assembly=False + ) + + # Create a new StockItem for this Part + StockItem.objects.create( + part=sub_part, + quantity=1000, + location=loc, + ) + + # Now, create an "alternative" for the BOM Item + BomItemSubstitute.objects.create( + bom_item=bom_item, + part=sub_part + ) + + # We should be able to filter the API list to just return this new part + response = self.get(url, data={'part': sub_part.pk}, expected_code=200) + self.assertEqual(len(response.data), 1) + + # We should also have more stock available to allocate against this BOM item! + response = self.get( + stock_url, + { + "bom_item": bom_item.pk, + }, + expected_code=200 + ) + + self.assertEqual(len(response.data), n_items + ii + 1) + + # There should now be 5 substitute parts available in the database + response = self.get(url, expected_code=200) + self.assertEqual(len(response.data), 5) + + class PartParameterTest(InvenTreeAPITestCase): """ Tests for the ParParameter API diff --git a/InvenTree/part/test_bom_item.py b/InvenTree/part/test_bom_item.py index be9740d128..cd3c77845c 100644 --- a/InvenTree/part/test_bom_item.py +++ b/InvenTree/part/test_bom_item.py @@ -1,8 +1,13 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals +from django.db import transaction + from django.test import TestCase import django.core.exceptions as django_exceptions from decimal import Decimal -from .models import Part, BomItem +from .models import Part, BomItem, BomItemSubstitute class BomItemTest(TestCase): @@ -130,3 +135,67 @@ class BomItemTest(TestCase): self.bob.get_bom_price_range(1, internal=True), (Decimal(27.5), Decimal(87.5)) ) + + def test_substitutes(self): + """ + Tests for BOM item substitutes + """ + + # We will make some subtitute parts for the "orphan" part + bom_item = BomItem.objects.get( + part=self.bob, + sub_part=self.orphan + ) + + # No substitute parts available + self.assertEqual(bom_item.substitutes.count(), 0) + + subs = [] + + for ii in range(5): + + # Create a new part + sub_part = Part.objects.create( + name=f"Orphan {ii}", + description="A substitute part for the orphan part", + component=True, + is_template=False, + assembly=False, + ) + + subs.append(sub_part) + + # Link it as a substitute part + BomItemSubstitute.objects.create( + bom_item=bom_item, + part=sub_part + ) + + # Try to link it again (this should fail as it is a duplicate substitute) + with self.assertRaises(django_exceptions.ValidationError): + with transaction.atomic(): + BomItemSubstitute.objects.create( + bom_item=bom_item, + part=sub_part + ) + + # There should be now 5 substitute parts available + self.assertEqual(bom_item.substitutes.count(), 5) + + # Try to create a substitute which points to the same sub-part (should fail) + with self.assertRaises(django_exceptions.ValidationError): + BomItemSubstitute.objects.create( + bom_item=bom_item, + part=self.orphan, + ) + + # Remove one substitute part + bom_item.substitutes.last().delete() + + self.assertEqual(bom_item.substitutes.count(), 4) + + for sub in subs: + sub.delete() + + # The substitution links should have been automatically removed + self.assertEqual(bom_item.substitutes.count(), 0) diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py index 5f2a9b1583..2995d45811 100644 --- a/InvenTree/part/test_views.py +++ b/InvenTree/part/test_views.py @@ -87,16 +87,6 @@ class PartDetailTest(PartViewTestCase): self.assertEqual(response.context['part'].pk, pk) self.assertEqual(response.context['category'], part.category) - self.assertFalse(response.context['editing_enabled']) - - def test_editable(self): - - pk = 1 - response = self.client.get(reverse('part-detail', args=(pk,)), {'edit': True}) - - self.assertEqual(response.status_code, 200) - self.assertTrue(response.context['editing_enabled']) - def test_part_detail_from_ipn(self): """ Test that we can retrieve a part detail page from part IPN: diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index ee8699dfea..5a4167ea05 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -404,20 +404,13 @@ class PartDetail(InvenTreeRoleMixin, DetailView): # Add in some extra context information based on query params def get_context_data(self, **kwargs): - """ Provide extra context data to template - - - If '?editing=True', set 'editing_enabled' context variable + """ + Provide extra context data to template """ context = super().get_context_data(**kwargs) part = self.get_object() - if str2bool(self.request.GET.get('edit', '')): - # Allow BOM editing if the part is active - context['editing_enabled'] = 1 if part.active else 0 - else: - context['editing_enabled'] = 0 - ctx = part.get_context_data(self.request) context.update(**ctx) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 798802ab81..00891d7b6a 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -72,7 +72,9 @@ {% if barcodes %}
- +