From 84a168fc07d4ced6ee3465a035edcb98eba3e305 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 25 Feb 2022 14:30:12 +1100 Subject: [PATCH 1/8] Merge pull request #2666 from matmair/matmair/issue2663 [BUG] Unable to create build output (cherry picked from commit 50a45474da3907a0ad0f1568cbf97708ad4b28dd) --- InvenTree/build/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index c7577fa68c..e708bf0b3b 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -236,6 +236,7 @@ class BuildOutputCreateSerializer(serializers.Serializer): auto_allocate = serializers.BooleanField( required=False, default=False, + allow_null=True, label=_('Auto Allocate Serial Numbers'), help_text=_('Automatically allocate required items with matching serial numbers'), ) From 801f7ff96e1265afd49c6a3bf65671c919ca4588 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 25 Feb 2022 17:47:26 +1100 Subject: [PATCH 2/8] Merge pull request #2673 from SchrodingersGat/build-order-allocating-substitutes Build order allocating substitutes (cherry picked from commit cbb88806cdc7660bb7adabf23d85c74acdcffeac) --- InvenTree/InvenTree/status_codes.py | 2 + InvenTree/build/models.py | 165 +++++------------- InvenTree/build/serializers.py | 13 +- .../build/templates/build/build_base.html | 4 +- InvenTree/build/templates/build/detail.html | 2 +- InvenTree/build/test_build.py | 44 ++--- .../migrations/0056_auto_20201110_1125.py | 7 +- InvenTree/stock/models.py | 7 +- .../stock/templates/stock/item_base.html | 9 +- InvenTree/templates/js/translated/stock.js | 4 +- InvenTree/users/models.py | 6 +- 11 files changed, 100 insertions(+), 163 deletions(-) diff --git a/InvenTree/InvenTree/status_codes.py b/InvenTree/InvenTree/status_codes.py index c8917d679b..ffe22039c9 100644 --- a/InvenTree/InvenTree/status_codes.py +++ b/InvenTree/InvenTree/status_codes.py @@ -258,6 +258,7 @@ class StockHistoryCode(StatusCode): # Build order codes BUILD_OUTPUT_CREATED = 50 BUILD_OUTPUT_COMPLETED = 55 + BUILD_CONSUMED = 57 # Sales order codes @@ -298,6 +299,7 @@ class StockHistoryCode(StatusCode): BUILD_OUTPUT_CREATED: _('Build order output created'), BUILD_OUTPUT_COMPLETED: _('Build order output completed'), + BUILD_CONSUMED: _('Consumed by build order'), RECEIVED_AGAINST_PURCHASE_ORDER: _('Received against purchase order') diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 74b75787e7..095a8cf70c 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -30,8 +30,6 @@ from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin from InvenTree.validators import validate_build_order_reference -import common.models - import InvenTree.fields import InvenTree.helpers import InvenTree.tasks @@ -479,8 +477,6 @@ class Build(MPTTModel, ReferenceIndexingMixin): outputs = self.get_build_outputs(complete=True) - # TODO - Ordering? - return outputs @property @@ -491,8 +487,6 @@ class Build(MPTTModel, ReferenceIndexingMixin): outputs = self.get_build_outputs(complete=False) - # TODO - Order by how "complete" they are? - return outputs @property @@ -563,7 +557,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): if self.remaining > 0: return False - if not self.areUntrackedPartsFullyAllocated(): + if not self.are_untracked_parts_allocated(): return False # No issues! @@ -584,7 +578,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): self.save() # Remove untracked allocated stock - self.subtractUntrackedStock(user) + self.subtract_allocated_stock(user) # Ensure that there are no longer any BuildItem objects # which point to thisFcan Build Order @@ -768,7 +762,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): output.delete() @transaction.atomic - def subtractUntrackedStock(self, user): + def subtract_allocated_stock(self, user): """ Called when the Build is marked as "complete", this function removes the allocated untracked items from stock. @@ -831,7 +825,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): self.save() - def requiredQuantity(self, part, output): + def required_quantity(self, bom_item, output=None): """ Get the quantity of a part required to complete the particular build output. @@ -840,12 +834,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): output - The particular build output (StockItem) """ - # Extract the BOM line item from the database - try: - bom_item = PartModels.BomItem.objects.get(part=self.part.pk, sub_part=part.pk) - quantity = bom_item.quantity - except (PartModels.BomItem.DoesNotExist): - quantity = 0 + quantity = bom_item.quantity if output: quantity *= output.quantity @@ -854,32 +843,32 @@ class Build(MPTTModel, ReferenceIndexingMixin): return quantity - def allocatedItems(self, part, output): + def allocated_bom_items(self, bom_item, output=None): """ - Return all BuildItem objects which allocate stock of to + Return all BuildItem objects which allocate stock of to + + Note that the bom_item may allow variants, or direct substitutes, + making things difficult. Args: - part - The part object + bom_item - The BomItem object output - Build output (StockItem). """ - # Remember, if 'variant' stock is allowed to be allocated, it becomes more complicated! - variants = part.get_descendants(include_self=True) - allocations = BuildItem.objects.filter( build=self, - stock_item__part__pk__in=[p.pk for p in variants], + bom_item=bom_item, install_into=output, ) return allocations - def allocatedQuantity(self, part, output): + def allocated_quantity(self, bom_item, output=None): """ Return the total quantity of given part allocated to a given build output. """ - allocations = self.allocatedItems(part, output) + allocations = self.allocated_bom_items(bom_item, output) allocated = allocations.aggregate( q=Coalesce( @@ -891,24 +880,24 @@ class Build(MPTTModel, ReferenceIndexingMixin): return allocated['q'] - def unallocatedQuantity(self, part, output): + def unallocated_quantity(self, bom_item, output=None): """ Return the total unallocated (remaining) quantity of a part against a particular output. """ - required = self.requiredQuantity(part, output) - allocated = self.allocatedQuantity(part, output) + required = self.required_quantity(bom_item, output) + allocated = self.allocated_quantity(bom_item, output) return max(required - allocated, 0) - def isPartFullyAllocated(self, part, output): + def is_bom_item_allocated(self, bom_item, output=None): """ - Returns True if the part has been fully allocated to the particular build output + Test if the supplied BomItem has been fully allocated! """ - return self.unallocatedQuantity(part, output) == 0 + return self.unallocated_quantity(bom_item, output) == 0 - def isFullyAllocated(self, output, verbose=False): + def is_fully_allocated(self, output): """ Returns True if the particular build output is fully allocated. """ @@ -919,53 +908,24 @@ class Build(MPTTModel, ReferenceIndexingMixin): else: bom_items = self.tracked_bom_items - fully_allocated = True - for bom_item in bom_items: - part = bom_item.sub_part - if not self.isPartFullyAllocated(part, output): - fully_allocated = False - - if verbose: - print(f"Part {part} is not fully allocated for output {output}") - else: - break + if not self.is_bom_item_allocated(bom_item, output): + return False # All parts must be fully allocated! - return fully_allocated + return True - def areUntrackedPartsFullyAllocated(self): + def are_untracked_parts_allocated(self): """ Returns True if the un-tracked parts are fully allocated for this BuildOrder """ - return self.isFullyAllocated(None) + return self.is_fully_allocated(None) - def allocatedParts(self, output): + def unallocated_bom_items(self, output): """ - Return a list of parts which have been fully allocated against a particular output - """ - - allocated = [] - - # If output is not specified, we are talking about "untracked" items - if output is None: - bom_items = self.untracked_bom_items - else: - bom_items = self.tracked_bom_items - - for bom_item in bom_items: - part = bom_item.sub_part - - if self.isPartFullyAllocated(part, output): - allocated.append(part) - - return allocated - - def unallocatedParts(self, output): - """ - Return a list of parts which have *not* been fully allocated against a particular output + Return a list of bom items which have *not* been fully allocated against a particular output """ unallocated = [] @@ -977,10 +937,9 @@ class Build(MPTTModel, ReferenceIndexingMixin): bom_items = self.tracked_bom_items for bom_item in bom_items: - part = bom_item.sub_part - if not self.isPartFullyAllocated(part, output): - unallocated.append(part) + if not self.is_bom_item_allocated(bom_item, output): + unallocated.append(bom_item) return unallocated @@ -1008,57 +967,6 @@ class Build(MPTTModel, ReferenceIndexingMixin): return parts - def availableStockItems(self, part, output): - """ - Returns stock items which are available for allocation to this build. - - Args: - part - Part object - output - The particular build output - """ - - # Grab initial query for items which are "in stock" and match the part - items = StockModels.StockItem.objects.filter( - StockModels.StockItem.IN_STOCK_FILTER - ) - - # Check if variants are allowed for this part - try: - bom_item = PartModels.BomItem.objects.get(part=self.part, sub_part=part) - allow_part_variants = bom_item.allow_variants - except PartModels.BomItem.DoesNotExist: - allow_part_variants = False - - if allow_part_variants: - parts = part.get_descendants(include_self=True) - items = items.filter(part__pk__in=[p.pk for p in parts]) - - else: - items = items.filter(part=part) - - # Exclude any items which have already been allocated - allocated = BuildItem.objects.filter( - build=self, - stock_item__part=part, - install_into=output, - ) - - items = items.exclude( - id__in=[item.stock_item.id for item in allocated.all()] - ) - - # Limit query to stock items which are "downstream" of the source location - if self.take_from is not None: - items = items.filter( - location__in=[loc for loc in self.take_from.getUniqueChildren()] - ) - - # Exclude expired stock items - if not common.models.InvenTreeSetting.get_setting('STOCK_ALLOW_EXPIRED_BUILD'): - items = items.exclude(StockModels.StockItem.EXPIRED_FILTER) - - return items - @property def is_active(self): """ Is this build active? An active build is either: @@ -1257,7 +1165,12 @@ class BuildItem(models.Model): if item.part.trackable: # Split the allocated stock if there are more available than allocated if item.quantity > self.quantity: - item = item.splitStock(self.quantity, None, user) + item = item.splitStock( + self.quantity, + None, + user, + code=StockHistoryCode.BUILD_CONSUMED, + ) # Make sure we are pointing to the new item self.stock_item = item @@ -1268,7 +1181,11 @@ class BuildItem(models.Model): item.save() else: # Simply remove the items from stock - item.take_stock(self.quantity, user) + item.take_stock( + self.quantity, + user, + code=StockHistoryCode.BUILD_CONSUMED + ) def getStockItemThumbnail(self): """ diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index e708bf0b3b..0a8964ee82 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -160,7 +160,7 @@ class BuildOutputSerializer(serializers.Serializer): if to_complete: # The build output must have all tracked parts allocated - if not build.isFullyAllocated(output): + if not build.is_fully_allocated(output): raise ValidationError(_("This build output is not fully allocated")) return output @@ -404,6 +404,10 @@ class BuildOutputCompleteSerializer(serializers.Serializer): data = self.validated_data + location = data['location'] + status = data['status'] + notes = data.get('notes', '') + outputs = data.get('outputs', []) # Mark the specified build outputs as "complete" @@ -415,8 +419,9 @@ class BuildOutputCompleteSerializer(serializers.Serializer): build.complete_build_output( output, request.user, - status=data['status'], - notes=data.get('notes', '') + location=location, + status=status, + notes=notes, ) @@ -436,7 +441,7 @@ class BuildCompleteSerializer(serializers.Serializer): build = self.context['build'] - if not build.areUntrackedPartsFullyAllocated() and not value: + if not build.are_untracked_parts_allocated() and not value: raise ValidationError(_('Required stock has not been fully allocated')) return value diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index 7340f1486d..cd7126a801 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -125,7 +125,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "Required build quantity has not yet been completed" %} {% endif %} - {% if not build.areUntrackedPartsFullyAllocated %} + {% if not build.are_untracked_parts_allocated %}
{% trans "Stock has not been fully allocated to this Build Order" %}
@@ -234,7 +234,7 @@ src="{% static 'img/blank_image.png' %}" {% else %} completeBuildOrder({{ build.pk }}, { - allocated: {% if build.areUntrackedPartsFullyAllocated %}true{% else %}false{% endif %}, + allocated: {% if build.are_untracked_parts_allocated %}true{% else %}false{% endif %}, completed: {% if build.remaining == 0 %}true{% else %}false{% endif %}, }); {% endif %} diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 28760c5316..f85ec9afa6 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -192,7 +192,7 @@
{% if build.has_untracked_bom_items %} {% if build.active %} - {% if build.areUntrackedPartsFullyAllocated %} + {% if build.are_untracked_parts_allocated %}
{% trans "Untracked stock has been fully allocated for this Build Order" %}
diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index 1a1f0b115e..116c705f61 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -62,20 +62,20 @@ class BuildTest(TestCase): ) # Create BOM item links for the parts - BomItem.objects.create( + self.bom_item_1 = BomItem.objects.create( part=self.assembly, sub_part=self.sub_part_1, quantity=5 ) - BomItem.objects.create( + self.bom_item_2 = BomItem.objects.create( part=self.assembly, sub_part=self.sub_part_2, quantity=3 ) # sub_part_3 is trackable! - BomItem.objects.create( + self.bom_item_3 = BomItem.objects.create( part=self.assembly, sub_part=self.sub_part_3, quantity=2 @@ -147,15 +147,15 @@ class BuildTest(TestCase): # None of the build outputs have been completed for output in self.build.get_build_outputs().all(): - self.assertFalse(self.build.isFullyAllocated(output)) + self.assertFalse(self.build.is_fully_allocated(output)) - self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_1, self.output_1)) - self.assertFalse(self.build.isPartFullyAllocated(self.sub_part_2, self.output_2)) + self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1, self.output_1)) + self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2, self.output_2)) - self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_1), 15) - self.assertEqual(self.build.unallocatedQuantity(self.sub_part_1, self.output_2), 35) - self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_1), 9) - self.assertEqual(self.build.unallocatedQuantity(self.sub_part_2, self.output_2), 21) + self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_1), 15) + self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_2), 35) + self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_1), 9) + self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_2), 21) self.assertFalse(self.build.is_complete) @@ -226,7 +226,7 @@ class BuildTest(TestCase): } ) - self.assertTrue(self.build.isFullyAllocated(self.output_1)) + self.assertTrue(self.build.is_fully_allocated(self.output_1)) # Partially allocate tracked stock against build output 2 self.allocate_stock( @@ -236,7 +236,7 @@ class BuildTest(TestCase): } ) - self.assertFalse(self.build.isFullyAllocated(self.output_2)) + self.assertFalse(self.build.is_fully_allocated(self.output_2)) # Partially allocate untracked stock against build self.allocate_stock( @@ -247,9 +247,9 @@ class BuildTest(TestCase): } ) - self.assertFalse(self.build.isFullyAllocated(None, verbose=True)) + self.assertFalse(self.build.is_fully_allocated(None)) - unallocated = self.build.unallocatedParts(None) + unallocated = self.build.unallocated_bom_items(None) self.assertEqual(len(unallocated), 2) @@ -260,19 +260,19 @@ class BuildTest(TestCase): } ) - self.assertFalse(self.build.isFullyAllocated(None, verbose=True)) + self.assertFalse(self.build.is_fully_allocated(None)) - unallocated = self.build.unallocatedParts(None) + unallocated = self.build.unallocated_bom_items(None) self.assertEqual(len(unallocated), 1) self.build.unallocateStock() - unallocated = self.build.unallocatedParts(None) + unallocated = self.build.unallocated_bom_items(None) self.assertEqual(len(unallocated), 2) - self.assertFalse(self.build.areUntrackedPartsFullyAllocated()) + self.assertFalse(self.build.are_untracked_parts_allocated()) # Now we "fully" allocate the untracked untracked items self.allocate_stock( @@ -283,7 +283,7 @@ class BuildTest(TestCase): } ) - self.assertTrue(self.build.areUntrackedPartsFullyAllocated()) + self.assertTrue(self.build.are_untracked_parts_allocated()) def test_cancel(self): """ @@ -331,9 +331,9 @@ class BuildTest(TestCase): } ) - self.assertTrue(self.build.isFullyAllocated(None, verbose=True)) - self.assertTrue(self.build.isFullyAllocated(self.output_1)) - self.assertTrue(self.build.isFullyAllocated(self.output_2)) + self.assertTrue(self.build.is_fully_allocated(None)) + self.assertTrue(self.build.is_fully_allocated(self.output_1)) + self.assertTrue(self.build.is_fully_allocated(self.output_2)) self.build.complete_build_output(self.output_1, None) diff --git a/InvenTree/part/migrations/0056_auto_20201110_1125.py b/InvenTree/part/migrations/0056_auto_20201110_1125.py index e78482db76..efb36b1812 100644 --- a/InvenTree/part/migrations/0056_auto_20201110_1125.py +++ b/InvenTree/part/migrations/0056_auto_20201110_1125.py @@ -1,5 +1,7 @@ # Generated by Django 3.0.7 on 2020-11-10 11:25 +import logging + from django.db import migrations from moneyed import CURRENCIES @@ -7,6 +9,9 @@ from django.db import migrations, connection from company.models import SupplierPriceBreak +logger = logging.getLogger('inventree') + + def migrate_currencies(apps, schema_editor): """ Migrate from the 'old' method of handling currencies, @@ -19,7 +24,7 @@ def migrate_currencies(apps, schema_editor): for the SupplierPriceBreak model, to a new django-money compatible currency. """ - print("Updating currency references for SupplierPriceBreak model...") + logger.info("Updating currency references for SupplierPriceBreak model...") # A list of available currency codes currency_codes = CURRENCIES.keys() diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 158d0a2640..42cc5b9f7a 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1311,6 +1311,7 @@ class StockItem(MPTTModel): """ notes = kwargs.get('notes', '') + code = kwargs.get('code', StockHistoryCode.SPLIT_FROM_PARENT) # Do not split a serialized part if self.serialized: @@ -1352,7 +1353,7 @@ class StockItem(MPTTModel): # Add a new tracking item for the new stock item new_stock.add_tracking_entry( - StockHistoryCode.SPLIT_FROM_PARENT, + code, user, notes=notes, deltas={ @@ -1530,7 +1531,7 @@ class StockItem(MPTTModel): return True @transaction.atomic - def take_stock(self, quantity, user, notes=''): + def take_stock(self, quantity, user, notes='', code=StockHistoryCode.STOCK_REMOVE): """ Remove items from stock """ @@ -1550,7 +1551,7 @@ class StockItem(MPTTModel): if self.updateQuantity(self.quantity - quantity): self.add_tracking_entry( - StockHistoryCode.STOCK_REMOVE, + code, user, notes=notes, deltas={ diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 7692d632f0..9979468357 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -409,7 +409,14 @@ {% trans "Tests" %} - {{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }} + + {{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }} + {% if item.passedAllRequiredTests %} + + {% else %} + + {% endif %} + {% endif %} {% if item.owner %} diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 10b1b71073..2d84f11e4a 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1554,11 +1554,11 @@ function locationDetail(row, showLink=true) { } else if (row.belongs_to) { // StockItem is installed inside a different StockItem text = `{% trans "Installed in Stock Item" %} ${row.belongs_to}`; - url = `/stock/item/${row.belongs_to}/installed/`; + url = `/stock/item/${row.belongs_to}/?display=installed-items`; } else if (row.customer) { // StockItem has been assigned to a customer text = '{% trans "Shipped to customer" %}'; - url = `/company/${row.customer}/assigned-stock/`; + url = `/company/${row.customer}/?display=assigned-stock`; } else if (row.sales_order) { // StockItem has been assigned to a sales order text = '{% trans "Assigned to Sales Order" %}'; diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index a95fd21385..c593fb49f3 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -451,7 +451,7 @@ def update_group_roles(group, debug=False): group.permissions.add(permission) if debug: # pragma: no cover - print(f"Adding permission {perm} to group {group.name}") + logger.info(f"Adding permission {perm} to group {group.name}") # Remove any extra permissions from the group for perm in permissions_to_delete: @@ -466,7 +466,7 @@ def update_group_roles(group, debug=False): group.permissions.remove(permission) if debug: # pragma: no cover - print(f"Removing permission {perm} from group {group.name}") + logger.info(f"Removing permission {perm} from group {group.name}") # Enable all action permissions for certain children models # if parent model has 'change' permission @@ -488,7 +488,7 @@ def update_group_roles(group, debug=False): permission = get_permission_object(child_perm) if permission: group.permissions.add(permission) - print(f"Adding permission {child_perm} to group {group.name}") + logger.info(f"Adding permission {child_perm} to group {group.name}") @receiver(post_save, sender=Group, dispatch_uid='create_missing_rule_sets') From 9841f478060a09b5611cade36756b93fc596eb1a Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 26 Feb 2022 18:36:08 +1100 Subject: [PATCH 3/8] Merge pull request #2676 from SchrodingersGat/location-permission-fix Stock location template fix (cherry picked from commit c882d1f89bca9fa52670fa2951cbf9b26c87ee67) --- InvenTree/stock/models.py | 79 +++++++++++++++++++ InvenTree/stock/templates/stock/item.html | 9 +-- .../stock/templates/stock/item_base.html | 36 +++------ InvenTree/stock/templates/stock/location.html | 35 ++++---- InvenTree/stock/views.py | 19 +++++ 5 files changed, 126 insertions(+), 52 deletions(-) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 42cc5b9f7a..64b47da6d3 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -63,6 +63,43 @@ class StockLocation(InvenTreeTree): help_text=_('Select Owner'), related_name='stock_locations') + def get_location_owner(self): + """ + Get the closest "owner" for this location. + + Start at this location, and traverse "up" the location tree until we find an owner + """ + + for loc in self.get_ancestors(include_self=True, ascending=True): + if loc.owner is not None: + return loc.owner + + return None + + def check_ownership(self, user): + """ + Check if the user "owns" (is one of the owners of) the location. + """ + + # Superuser accounts automatically "own" everything + if user.is_superuser: + return True + + ownership_enabled = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') + + if not ownership_enabled: + # Location ownership function is not enabled, so return True + return True + + owner = self.get_location_owner() + + if owner is None: + # No owner set, for this location or any location above + # So, no ownership checks to perform! + return True + + return user in owner.get_related_owners(include_group=True) + def get_absolute_url(self): return reverse('stock-location-detail', kwargs={'pk': self.id}) @@ -614,6 +651,48 @@ class StockItem(MPTTModel): help_text=_('Select Owner'), related_name='stock_items') + def get_item_owner(self): + """ + Return the closest "owner" for this StockItem. + + - If the item has an owner set, return that + - If the item is "in stock", check the StockLocation + - Otherwise, return None + """ + + if self.owner is not None: + return self.owner + + if self.in_stock and self.location is not None: + loc_owner = self.location.get_location_owner() + + if loc_owner: + return loc_owner + + return None + + def check_ownership(self, user): + """ + Check if the user "owns" (or is one of the owners of) the item + """ + + # Superuser accounts automatically "own" everything + if user.is_superuser: + return True + + ownership_enabled = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') + + if not ownership_enabled: + # Location ownership function is not enabled, so return True + return True + + owner = self.get_item_owner() + + if owner is None: + return True + + return user in owner.get_related_owners(include_group=True) + def is_stale(self): """ Returns True if this Stock item is "stale". diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index f42a768069..113fefb9b1 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -18,18 +18,11 @@

{% trans "Stock Tracking Information" %}

{% include "spacer.html" %}
- {% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %} - {% if owner_control.value == "True" %} - {% authorized_owners item.owner as owners %} - {% endif %} - - {% if owner_control.value == "False" or owner_control.value == "True" and user in owners %} - {% if roles.stock.change and not item.is_building %} + {% if user_owns_item and roles.stock.change and not item.is_building %} {% endif %} - {% endif %}
diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 9979468357..c52d101afb 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -59,14 +59,7 @@ - - -{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %} -{% if owner_control.value == "True" %} - {% authorized_owners item.owner as owners %} -{% endif %} - -{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %} +{% if user_owns_item %} {% if roles.stock.change and not item.is_building %}
@@ -219,24 +212,8 @@ -{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %} -{% if owner_control.value == "True" %} - {% authorized_owners item.owner as owners %} -{% endif %} -
- {% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %} - {% if owner_control.value == "True" %} - {% authorized_owners item.owner as owners %} - - {% if not user in owners and not user.is_superuser %} -
- {% trans "You are not in the list of owners of this item. This stock item cannot be edited." %}
-
- {% endif %} - {% endif %} - {% if item.is_building %}
{% trans "This stock item is in production and cannot be edited." %}
@@ -419,11 +396,18 @@ {% endif %} - {% if item.owner %} + {% if ownership_enabled and item_owner %} {% trans "Owner" %} - {{ item.owner }} + + {{ item_owner }} + {% if not user_owns_item %} + + {% trans "Read only" %} + + {% endif %} + {% endif %} diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 39b9faedb4..4c98db529b 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -20,6 +20,7 @@ {% endblock %} {% block actions %} + {% if location and user.is_staff and roles.stock_location.change %} {% url 'admin:stock_stocklocation_change' location.pk as url %} @@ -38,7 +39,7 @@
-{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %} +{% if user_owns_location %} {% if roles.stock.change %}
{% endif %} -{% endif %} {% endblock %} {% block details_left %} @@ -106,23 +105,23 @@ {% trans "Top level stock location" %} {% endif %} + {% if ownership_enabled and location_owner %} + + + {% trans "Location Owner" %} + + {{ location_owner }} + {% if not user_owns_location %} + + {% trans "Read only" %} + + {% endif %} + + + {% endif %} {% endblock details_left %} -{% block details_below %} -{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %} -{% if owner_control.value == "True" %} - {% authorized_owners location.owner as owners %} - - {% if location and not user in owners and not user.is_superuser %} -
- {% trans "You are not in the list of owners of this location. This stock location cannot be edited." %}
-
- {% endif %} -{% endif %} - -{% endblock details_below %} - {% block details_right %} {% if location %} diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 9aa70255b1..d1fde25b0a 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -63,6 +63,11 @@ class StockIndex(InvenTreeRoleMixin, ListView): context['loc_count'] = StockLocation.objects.count() context['stock_count'] = StockItem.objects.count() + # No 'ownership' checks are necessary for the top-level StockLocation view + context['user_owns_location'] = True + context['location_owner'] = None + context['ownership_enabled'] = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') + return context @@ -76,6 +81,16 @@ class StockLocationDetail(InvenTreeRoleMixin, DetailView): queryset = StockLocation.objects.all() model = StockLocation + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + context['ownership_enabled'] = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') + context['location_owner'] = context['location'].get_location_owner() + context['user_owns_location'] = context['location'].check_ownership(self.request.user) + + return context + class StockItemDetail(InvenTreeRoleMixin, DetailView): """ @@ -126,6 +141,10 @@ class StockItemDetail(InvenTreeRoleMixin, DetailView): # We only support integer serial number progression pass + data['ownership_enabled'] = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') + data['item_owner'] = self.object.get_item_owner() + data['user_owns_item'] = self.object.check_ownership(self.request.user) + return data def get(self, request, *args, **kwargs): From 7a18801d0a7c7ecb559f802e2f7a8264ca67e67e Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 26 Feb 2022 18:37:49 +1100 Subject: [PATCH 4/8] Bump version number to v0.6.1 --- InvenTree/InvenTree/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index bbc022204e..50c07a610e 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -9,7 +9,7 @@ import re import common.models # InvenTree software version -INVENTREE_SW_VERSION = "0.6.0" +INVENTREE_SW_VERSION = "0.6.1" # InvenTree API version INVENTREE_API_VERSION = 26 From 1d04db14f2b0ed8b0513833075e9da148d52412c Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 28 Feb 2022 14:15:40 +1100 Subject: [PATCH 5/8] Merge pull request #2682 from matmair/matmair/issue2672 Fix git version bug (cherry picked from commit 010ce48ce0e3b78eb9bca5340ab8c1b8017776d9) --- InvenTree/plugin/apps.py | 7 ++++++ InvenTree/plugin/helpers.py | 41 +++++++++++++++++++++++++++++------- InvenTree/plugin/registry.py | 1 + 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py index dd75e3c8fb..75063c6b3c 100644 --- a/InvenTree/plugin/apps.py +++ b/InvenTree/plugin/apps.py @@ -5,11 +5,13 @@ import logging from django.apps import AppConfig from django.conf import settings +from django.utils.translation import ugettext_lazy as _ from maintenance_mode.core import set_maintenance_mode from InvenTree.ready import isImportingData from plugin import registry +from plugin.helpers import check_git_version, log_error logger = logging.getLogger('inventree') @@ -34,3 +36,8 @@ class PluginAppConfig(AppConfig): # drop out of maintenance # makes sure we did not have an error in reloading and maintenance is still active set_maintenance_mode(False) + + # check git version + registry.git_is_modern = check_git_version() + if not registry.git_is_modern: # pragma: no cover # simulating old git seems not worth it for coverage + log_error(_('Your enviroment has an outdated git version. This prevents InvenTree from loading plugin details.'), 'load') diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py index 2271c01d98..1a5089aefe 100644 --- a/InvenTree/plugin/helpers.py +++ b/InvenTree/plugin/helpers.py @@ -94,21 +94,46 @@ def get_git_log(path): """ Get dict with info of the last commit to file named in path """ - path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:] - command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path] + from plugin import registry + output = None - try: - output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1] - if output: - output = output.split('\n') - except subprocess.CalledProcessError: # pragma: no cover - pass + if registry.git_is_modern: + path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:] + command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path] + try: + output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1] + if output: + output = output.split('\n') + except subprocess.CalledProcessError: # pragma: no cover + pass if not output: output = 7 * [''] # pragma: no cover + return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4], 'verified': output[5], 'key': output[6]} +def check_git_version(): + """returns if the current git version supports modern features""" + + # get version string + try: + output = str(subprocess.check_output(['git', '--version'], cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8') + except subprocess.CalledProcessError: # pragma: no cover + return False + + # process version string + try: + version = output[12:-1].split(".") + if len(version) > 1 and version[0] == '2': + if len(version) > 2 and int(version[1]) >= 22: + return True + except ValueError: # pragma: no cover + pass + + return False + + class GitStatus: """ Class for resolving git gpg singing state diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index aec38cc623..40a4a4e2d9 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -52,6 +52,7 @@ class PluginsRegistry: # flags self.is_loading = False self.apps_loading = True # Marks if apps were reloaded yet + self.git_is_modern = True # Is a modern version of git available # integration specific self.installed_apps = [] # Holds all added plugin_paths From 1ed2b47353e747730b7744038e51e95838e5ad8d Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 2 Mar 2022 00:08:18 +1100 Subject: [PATCH 6/8] Merge pull request #2696 from SchrodingersGat/bom-table-fix Fix some small template / JS errors on the "part" page (cherry picked from commit f585ee6db77f376de10c28c188d9283c5a9e5cf6) --- InvenTree/part/templates/part/detail.html | 17 +++++++++++++++-- InvenTree/templates/js/translated/tables.js | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 2266b39048..5b92a3af01 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -122,7 +122,13 @@

{% trans "Sales Order Allocations" %}

-
+ +
+
+ {% include "filter_list.html" with id="salesorderallocation" %} +
+
+
@@ -342,7 +348,12 @@

{% trans "Build Order Allocations" %}

-
+
+
+ {% include "filter_list.html" with id="buildorderallocation" %} +
+
+
@@ -722,6 +733,7 @@ }); // Load the BOM table data in the pricing view + {% if part.has_bom and roles.sales_order.view %} loadBomTable($("#bom-pricing-table"), { editable: false, bom_url: "{% url 'api-bom-list' %}", @@ -729,6 +741,7 @@ parent_id: {{ part.id }} , sub_part_detail: true, }); + {% endif %} onPanelLoad("purchase-orders", function() { loadPartPurchaseOrderTable( diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js index 4c9bec0476..c2418dbe78 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -278,7 +278,7 @@ $.fn.inventreeTable = function(options) { } }); } else { - console.log(`Could not get list of visible columns for column '${tableName}'`); + console.log(`Could not get list of visible columns for table '${tableName}'`); } } From 8e01732a5a99d1f0257dbad15034d012a8752e8f Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 2 Mar 2022 00:32:35 +1100 Subject: [PATCH 7/8] Merge pull request #2697 from SchrodingersGat/allocation-bug Bug fix for BuildOrder.bom_items (cherry picked from commit 35451be4f2d1380a77111c147803f8dc4d4fd6b6) --- InvenTree/build/models.py | 4 +--- InvenTree/part/models.py | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 095a8cf70c..01c2c781e9 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -383,9 +383,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): Returns the BOM items for the part referenced by this BuildOrder """ - return self.part.bom_items.all().prefetch_related( - 'sub_part' - ) + return self.part.get_bom_items() @property def tracked_bom_items(self): diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 33ad8bf612..09e1f77542 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1453,7 +1453,9 @@ class Part(MPTTModel): By default, will include inherited BOM items """ - return BomItem.objects.filter(self.get_bom_item_filter(include_inherited=include_inherited)) + queryset = BomItem.objects.filter(self.get_bom_item_filter(include_inherited=include_inherited)) + + return queryset.prefetch_related('sub_part') def get_installed_part_options(self, include_inherited=True, include_variants=True): """ From 8426f403a6d0ba82ddc5ab78fe90ddda28ce8228 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 2 Mar 2022 00:32:59 +1100 Subject: [PATCH 8/8] Merge pull request #2698 from SchrodingersGat/delete-serialized-stock Allows deletion of serialized stock (cherry picked from commit 0d2bfa6839a554bc9129f13e9e47638355a0d022) --- InvenTree/part/templates/part/part_base.html | 4 ++-- InvenTree/stock/models.py | 4 ---- InvenTree/stock/templates/stock/item_base.html | 2 +- InvenTree/templates/stock_table.html | 8 ++++---- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index d2505a57f7..67baaf0636 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -59,13 +59,13 @@