From af53b341f002e981ee3cb9da4e9f0e0d187d45a3 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 11 May 2021 17:17:48 +1000
Subject: [PATCH] Replace "addTrasactionNote" function with
 "add_tracking_entry"

- Does not add translated strings to the database
---
 InvenTree/build/models.py                     |  12 +-
 InvenTree/order/models.py                     |  24 +-
 .../migrations/0060_auto_20210511_1713.py     |  28 ++
 .../migrations/0061_auto_20210511_0911.py     |  59 ++++
 InvenTree/stock/models.py                     | 272 ++++++++++++------
 5 files changed, 299 insertions(+), 96 deletions(-)
 create mode 100644 InvenTree/stock/migrations/0060_auto_20210511_1713.py
 create mode 100644 InvenTree/stock/migrations/0061_auto_20210511_0911.py

diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py
index a278b4e17c..c80c0e8523 100644
--- a/InvenTree/build/models.py
+++ b/InvenTree/build/models.py
@@ -22,7 +22,7 @@ from markdownx.models import MarkdownxField
 
 from mptt.models import MPTTModel, TreeForeignKey
 
-from InvenTree.status_codes import BuildStatus, StockStatus
+from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
 from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
 from InvenTree.validators import validate_build_order_reference
 from InvenTree.models import InvenTreeAttachment
@@ -811,6 +811,7 @@ class Build(MPTTModel):
         # Select the location for the build output
         location = kwargs.get('location', self.destination)
         status = kwargs.get('status', StockStatus.OK)
+        notes = kwargs.get('notes', '')
 
         # List the allocated BuildItem objects for the given output
         allocated_items = output.items_to_install.all()
@@ -834,10 +835,13 @@ class Build(MPTTModel):
 
         output.save()
 
-        output.addTransactionNote(
-            _('Completed build output'),
+        output.add_tracking_entry(
+            StockHistoryCode.BUILD_OUTPUT_COMPLETED,
             user,
-            system=True
+            notes=notes,
+            deltas={
+                'status': status,
+            }
         )
 
         # Increase the completed quantity for this build
diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py
index 534775ebaf..8572d0c634 100644
--- a/InvenTree/order/models.py
+++ b/InvenTree/order/models.py
@@ -28,7 +28,7 @@ from company.models import Company, SupplierPart
 
 from InvenTree.fields import RoundingDecimalField
 from InvenTree.helpers import decimal2string, increment, getSetting
-from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus
+from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus, StockHistoryCode
 from InvenTree.models import InvenTreeAttachment
 
 
@@ -336,10 +336,12 @@ class PurchaseOrder(Order):
         return self.pending_line_items().count() == 0
 
     @transaction.atomic
-    def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None):
+    def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None, **kwargs):
         """ Receive a line item (or partial line item) against this PO
         """
 
+        notes = kwargs.get('notes', '')
+
         if not self.status == PurchaseOrderStatus.PLACED:
             raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")})
 
@@ -369,8 +371,22 @@ class PurchaseOrder(Order):
             text = _("Received items")
             note = _('Received {n} items against order {name}').format(n=quantity, name=str(self))
 
-            # Add a new transaction note to the newly created stock item
-            stock.addTransactionNote(text, user, note)
+            tracking_info = {
+                'status': status,
+                'purchaseorder': self.pk,
+                'quantity': quantity,
+            }
+
+            if location:
+                tracking_info['location'] = location.pk
+
+            stock.add_tracking_entry(
+                StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER,
+                user,
+                notes=notes,
+                url=self.get_absolute_url(),
+                deltas=tracking_info
+            )
 
         # Update the number of parts received against the particular line item
         line.received += quantity
diff --git a/InvenTree/stock/migrations/0060_auto_20210511_1713.py b/InvenTree/stock/migrations/0060_auto_20210511_1713.py
new file mode 100644
index 0000000000..752b070750
--- /dev/null
+++ b/InvenTree/stock/migrations/0060_auto_20210511_1713.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.2 on 2021-05-11 07:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('stock', '0059_auto_20210404_2016'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='stockitemtracking',
+            name='deltas',
+            field=models.JSONField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='stockitemtracking',
+            name='tracking_type',
+            field=models.IntegerField(default=0),
+        ),
+        migrations.AlterField(
+            model_name='stockitemtracking',
+            name='title',
+            field=models.CharField(blank=True, help_text='Tracking entry title', max_length=250, verbose_name='Title'),
+        ),
+    ]
diff --git a/InvenTree/stock/migrations/0061_auto_20210511_0911.py b/InvenTree/stock/migrations/0061_auto_20210511_0911.py
new file mode 100644
index 0000000000..087863c348
--- /dev/null
+++ b/InvenTree/stock/migrations/0061_auto_20210511_0911.py
@@ -0,0 +1,59 @@
+# Generated by Django 3.2 on 2021-05-10 23:11
+
+from django.db import migrations
+
+
+def update_history(apps, schema_editor):
+    """
+    Update each existing StockItemTracking object,
+    convert the recorded "quantity" to a delta
+    """
+
+    StockItem = apps.get_model('stock', 'stockitem')
+    StockItemTracking = apps.get_model('stock', 'stockitemtracking')
+
+    update_count = 0
+
+    for item in StockItem.objects.all():
+
+        history = StockItemTracking.objects.filter(item=item).order_by('date')
+
+        if history.count() == 0:
+            continue
+
+        quantity = history[0].quantity
+
+        for entry in history:
+            
+            q = entry.quantity
+
+            if not q == quantity:
+
+                entry.deltas = {
+                    'quantity': float(q),
+                }
+
+                entry.save()
+
+                update_count += 1
+
+                quantity = q
+
+    print(f"Updated {update_count} StockItemHistory entries")
+
+
+def reverse_update(apps, schema_editor):
+    """
+    """
+    pass
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('stock', '0060_auto_20210511_1713'),
+    ]
+
+    operations = [
+        migrations.RunPython(update_history, reverse_code=reverse_update)
+    ]
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index 3aecfff2c2..408c598141 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -34,7 +34,7 @@ import common.models
 import report.models
 import label.models
 
-from InvenTree.status_codes import StockStatus
+from InvenTree.status_codes import StockStatus, StockHistoryCode
 from InvenTree.models import InvenTreeTree, InvenTreeAttachment
 from InvenTree.fields import InvenTreeURLField
 
@@ -198,14 +198,18 @@ class StockItem(MPTTModel):
 
         if add_note:
 
-            note = _('Created new stock item for {part}').format(part=str(self.part))
+            tracking_info = {
+                'quantity': self.quantity,
+                'status': self.status,
+            }
 
-            # This StockItem is being saved for the first time
-            self.addTransactionNote(
-                _('Created stock item'),
+            if self.location:
+                tracking_info['location'] = self.location.pk
+
+            self.add_tracking_entry(
+                StockHistoryCode.CREATED,
                 user,
-                note,
-                system=True
+                deltas=tracking_info
             )
 
     @property
@@ -610,31 +614,45 @@ class StockItem(MPTTModel):
 
         # TODO - Remove any stock item allocations from this stock item
 
-        item.addTransactionNote(
-            _("Assigned to Customer"),
+        item.add_tracking_entry(
+            StockHistoryCode.SENT_TO_CUSTOMER,
             user,
-            notes=_("Manually assigned to customer {name}").format(name=customer.name),
-            system=True
+            {
+                'customer': customer.id,
+                'customer_name': customer.name,
+            },
+            notes=notes,
         )
 
         # Return the reference to the stock item
         return item
 
-    def returnFromCustomer(self, location, user=None):
+    def returnFromCustomer(self, location, user=None, **kwargs):
         """
         Return stock item from customer, back into the specified location.
         """
 
-        self.addTransactionNote(
-            _("Returned from customer {name}").format(name=self.customer.name),
+        notes = kwargs.get('notes', '')
+
+        tracking_info = {}
+
+        if location:
+            tracking_info['location'] = location.id
+            tracking_info['location_name'] = location.name
+
+        if self.customer:
+            tracking_info['customer'] = customer.id
+            tracking_info['customer_name'] = customer.name
+
+        self.add_tracking_entry(
+            StockHistoryCode.RETURNED_FROM_CUSTOMER,
             user,
-            notes=_("Returned to location {loc}").format(loc=location.name),
-            system=True
+            notes=notes,
+            deltas=tracking_info
         )
 
         self.customer = None
         self.location = location
-        self.sales_order = None
 
         self.save()
 
@@ -788,18 +806,25 @@ class StockItem(MPTTModel):
         stock_item.save()
 
         # Add a transaction note to the other item
-        stock_item.addTransactionNote(
-            _('Installed into stock item {pk}').format(str(self.pk)),
+        stock_item.add_tracking_entry(
+            StockHistoryCode.INSTALLED_INTO_ASSEMBLY,
             user,
             notes=notes,
-            url=self.get_absolute_url()
+            url=self.get_absolute_url(),
+            deltas={
+                'assembly': self.pk,
+            }
         )
 
-        # Add a transaction note to this item
-        self.addTransactionNote(
-            _('Installed stock item {pk}').format(str(stock_item.pk)),
-            user, notes=notes,
-            url=stock_item.get_absolute_url()
+        # Add a transaction note to this item (the assembly)
+        self.add_tracking_entry(
+            StockHistoryCode.INSTALLED_CHILD_ITEM,
+            user,
+            notes=notes,
+            url=stock_item.get_absolute_url(),
+            deltas={
+                'stockitem': stock_item.pk,
+            }
         )
 
     @transaction.atomic
@@ -820,32 +845,41 @@ class StockItem(MPTTModel):
         # TODO - Are there any other checks that need to be performed at this stage?
 
         # Add a transaction note to the parent item
-        self.belongs_to.addTransactionNote(
-            _("Uninstalled stock item {pk}").format(pk=str(self.pk)),
+        self.belongs_to.add_tracking_entry(
+            StockHistoryCode.REMOVED_CHILD_ITEM,
             user,
+            deltas={
+                'stockitem': self.pk, 
+            },
             notes=notes,
             url=self.get_absolute_url(),
         )
 
+        tracking_info = {
+            'assembly': self.belongs_to.pk
+        }
+
+        if location:
+            tracking_info['location'] = location.pk
+            tracking_info['location_name'] = location.name
+            url = location.get_absolute_url()
+        else:
+            url = ''
+
+        self.add_tracking_entry(
+            StockHistoryCode.REMOVED_FROM_ASSEMBLY,
+            user,
+            notes=notes,
+            url=url,
+            deltas=tracking_info
+        )
+
         # Mark this stock item as *not* belonging to anyone
         self.belongs_to = None
         self.location = location
 
         self.save()
 
-        if location:
-            url = location.get_absolute_url()
-        else:
-            url = ''
-
-        # Add a transaction note!
-        self.addTransactionNote(
-            _('Uninstalled into location {loc}').formaT(loc=str(location)),
-            user,
-            notes=notes,
-            url=url
-        )
-
     @property
     def children(self):
         """ Return a list of the child items which have been split from this stock item """
@@ -901,24 +935,30 @@ class StockItem(MPTTModel):
     def has_tracking_info(self):
         return self.tracking_info_count > 0
 
-    def addTransactionNote(self, title, user, notes='', url='', system=True):
-        """ Generation a stock transaction note for this item.
+    def add_tracking_entry(self, entry_type, user, deltas={}, notes='', url=''):
+        """
+        Add a history tracking entry for this StockItem
 
-        Brief automated note detailing a movement or quantity change.
+        Args:
+            entry_type - Integer code describing the "type" of historical action (see StockHistoryCode)
+            user - The user performing this action
+            deltas - A map of the changes made to the model
+            notes - User notes associated with this tracking entry
+            url - Optional URL associated with this tracking entry
         """
 
-        track = StockItemTracking.objects.create(
+        entry = StockItemTracking.objects.create(
             item=self,
-            title=title,
+            tracking_type=entry_type,
             user=user,
-            quantity=self.quantity,
-            date=datetime.now().date(),
+            date=datetime.now(),
             notes=notes,
+            deltas=deltas,
             link=url,
-            system=system
+            system=True
         )
 
-        track.save()
+        entry.save()
 
     @transaction.atomic
     def serializeStock(self, quantity, serials, user, notes='', location=None):
@@ -991,10 +1031,17 @@ class StockItem(MPTTModel):
             new_item.copyTestResultsFrom(self)
 
             # Create a new stock tracking item
-            new_item.addTransactionNote(_('Add serial number'), user, notes=notes)
+            new_item.add_tracking_entry(
+                StockHistoryCode.ASSIGNED_SERIAL,
+                user,
+                notes=notes,
+                deltas={
+                    'serial': serial,
+                }
+            )
 
         # Remove the equivalent number of items
-        self.take_stock(quantity, user, notes=_('Serialized {n} items').format(n=quantity))
+        self.take_stock(quantity, user, notes=notes)
 
     @transaction.atomic
     def copyHistoryFrom(self, other):
@@ -1018,7 +1065,7 @@ class StockItem(MPTTModel):
             result.save()
 
     @transaction.atomic
-    def splitStock(self, quantity, location, user):
+    def splitStock(self, quantity, location, user, **kwargs):
         """ Split this stock item into two items, in the same location.
         Stock tracking notes for this StockItem will be duplicated,
         and added to the new StockItem.
@@ -1032,6 +1079,8 @@ class StockItem(MPTTModel):
             The new item will have a different StockItem ID, while this will remain the same.
         """
 
+        notes = kwargs.get('notes', '')
+
         # Do not split a serialized part
         if self.serialized:
             return self
@@ -1071,17 +1120,20 @@ class StockItem(MPTTModel):
         new_stock.copyTestResultsFrom(self)
 
         # Add a new tracking item for the new stock item
-        new_stock.addTransactionNote(
-            _("Split from existing stock"),
+        new_stock.add_tracking_entry(
+            StockHistoryCode.SPLIT_FROM_PARENT,
             user,
-            _('Split {n} items').format(n=helpers.normalize(quantity))
+            notes=notes,
+            deltas={
+                'stockitem': self.pk,
+            }
         )
 
         # Remove the specified quantity from THIS stock item
         self.take_stock(
             quantity,
             user,
-            f"{_('Split')} {quantity} {_('items into new stock item')}"
+            notes=notes
         )
 
         # Return a copy of the "new" stock item
@@ -1138,11 +1190,21 @@ class StockItem(MPTTModel):
 
         self.location = location
 
-        self.addTransactionNote(
-            msg,
+        tracking_info = {}
+
+        if location:
+            tracking_info['location'] = location.pk
+            url = location.get_absolute_url()
+        else:
+            url = ''
+
+        self.add_tracking_entry(
+            StockHistoryCode.STOCK_MOVE,
             user,
             notes=notes,
-            system=True)
+            deltas=tracking_info,
+            url=url,
+        )
 
         self.save()
 
@@ -1202,13 +1264,13 @@ class StockItem(MPTTModel):
 
         if self.updateQuantity(count):
 
-            text = _('Counted {n} items').format(n=helpers.normalize(count))
-
-            self.addTransactionNote(
-                text,
+            self.add_tracking_entry(
+                StockHistoryCode.STOCK_COUNT,
                 user,
                 notes=notes,
-                system=True
+                deltas={
+                    'quantity': self.quantity,
+                }
             )
 
         return True
@@ -1234,13 +1296,15 @@ class StockItem(MPTTModel):
             return False
 
         if self.updateQuantity(self.quantity + quantity):
-            text = _('Added {n} items').format(n=helpers.normalize(quantity))
 
-            self.addTransactionNote(
-                text,
+            self.add_tracking_entry(
+                StockHistoryCode.STOCK_ADD,
                 user,
                 notes=notes,
-                system=True
+                deltas={
+                    'added': quantity,
+                    'quantity': self.quantity
+                }
             )
 
         return True
@@ -1264,12 +1328,15 @@ class StockItem(MPTTModel):
 
         if self.updateQuantity(self.quantity - quantity):
 
-            text = _('Removed {n1} items').format(n1=helpers.normalize(quantity))
-
-            self.addTransactionNote(text,
-                                    user,
-                                    notes=notes,
-                                    system=True)
+            self.add_tracking_entry(
+                StockHistoryCode.STOCK_REMOVE,
+                user,
+                notes=notes,
+                deltas={
+                    'removed': quantity,
+                    'quantity': self.quantity,
+                }
+            )
 
         return True
 
@@ -1527,30 +1594,57 @@ class StockItemAttachment(InvenTreeAttachment):
 
 
 class StockItemTracking(models.Model):
-    """ Stock tracking entry - breacrumb for keeping track of automated stock transactions
+    """
+    Stock tracking entry - used for tracking history of a particular StockItem
+
+    Note: 2021-05-11
+    The legacy StockTrackingItem model contained very litle information about the "history" of the item.
+    In fact, only the "quantity" of the item was recorded at each interaction.
+    Also, the "title" was translated at time of generation, and thus was not really translateable.
+    The "new" system tracks all 'delta' changes to the model,
+    and tracks change "type" which can then later be translated
+
 
     Attributes:
-        item: Link to StockItem
+        item: ForeignKey reference to a particular StockItem
         date: Date that this tracking info was created
-        title: Title of this tracking info (generated by system)
+        title: Title of this tracking info (legacy, no longer used!)
+        tracking_type: The type of tracking information
         notes: Associated notes (input by user)
         link: Optional URL to external page
         user: The user associated with this tracking info
+        deltas: The changes associated with this history item
         quantity: The StockItem quantity at this point in time
     """
 
     def get_absolute_url(self):
         return '/stock/track/{pk}'.format(pk=self.id)
-        # return reverse('stock-tracking-detail', kwargs={'pk': self.id})
 
-    item = models.ForeignKey(StockItem, on_delete=models.CASCADE,
-                             related_name='tracking_info')
+    tracking_type = models.IntegerField(
+        default=StockHistoryCode.LEGACY,
+    )
+
+    item = models.ForeignKey(
+        StockItem,
+        on_delete=models.CASCADE,
+        related_name='tracking_info'
+    )
 
     date = models.DateTimeField(auto_now_add=True, editable=False)
 
-    title = models.CharField(blank=False, max_length=250, verbose_name=_('Title'), help_text=_('Tracking entry title'))
+    title = models.CharField(
+        blank=True,
+        max_length=250,
+        verbose_name=_('Title'),
+        help_text=_('Tracking entry title')
+    )
 
-    notes = models.CharField(blank=True, max_length=512, verbose_name=_('Notes'), help_text=_('Entry notes'))
+    notes = models.CharField(
+        blank=True,
+        max_length=512,
+        verbose_name=_('Notes'),
+        help_text=_('Entry notes')
+    )
 
     link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page for further information'))
 
@@ -1558,13 +1652,15 @@ class StockItemTracking(models.Model):
 
     system = models.BooleanField(default=False)
 
-    quantity = models.DecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, verbose_name=_('Quantity'))
+    deltas = models.JSONField(null=True, blank=True)
 
-    # TODO
-    # image = models.ImageField(upload_to=func, max_length=255, null=True, blank=True)
-
-    # TODO
-    # file = models.FileField()
+    quantity = models.DecimalField(
+        max_digits=15,
+        decimal_places=5,
+        validators=[MinValueValidator(0)],
+        default=1,
+        verbose_name=_('Quantity')
+    )
 
 
 def rename_stock_item_test_result_attachment(instance, filename):