diff --git a/InvenTree/InvenTree/context.py b/InvenTree/InvenTree/context.py index 669b55b0c0..3e1f98ffc2 100644 --- a/InvenTree/InvenTree/context.py +++ b/InvenTree/InvenTree/context.py @@ -6,6 +6,7 @@ Provides extra global data to all templates. from InvenTree.status_codes import SalesOrderStatus, PurchaseOrderStatus from InvenTree.status_codes import BuildStatus, StockStatus +from InvenTree.status_codes import StockHistoryCode import InvenTree.status @@ -65,6 +66,7 @@ def status_codes(request): 'PurchaseOrderStatus': PurchaseOrderStatus, 'BuildStatus': BuildStatus, 'StockStatus': StockStatus, + 'StockHistoryCode': StockHistoryCode, } diff --git a/InvenTree/InvenTree/middleware.py b/InvenTree/InvenTree/middleware.py index f30d77ad3b..b905e86795 100644 --- a/InvenTree/InvenTree/middleware.py +++ b/InvenTree/InvenTree/middleware.py @@ -77,12 +77,20 @@ class AuthRequiredMiddleware(object): if request.path_info == reverse_lazy('logout'): return HttpResponseRedirect(reverse_lazy('login')) - login = reverse_lazy('login') + path = request.path_info - if not request.path_info == login and not request.path_info.startswith('/api/'): + # List of URL endpoints we *do not* want to redirect to + urls = [ + reverse_lazy('login'), + reverse_lazy('logout'), + reverse_lazy('admin:login'), + reverse_lazy('admin:logout'), + ] + + if path not in urls and not path.startswith('/api/'): # Save the 'next' parameter to pass through to the login view - return redirect('%s?next=%s' % (login, request.path)) + return redirect('%s?next=%s' % (reverse_lazy('login'), request.path)) # Code to be executed for each request/response after # the view is called. diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index e7b8aeb71e..e3191405d9 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -507,7 +507,7 @@ padding-right: 6px; padding-top: 3px; padding-bottom: 2px; -}; +} .panel-heading .badge { float: right; @@ -568,7 +568,7 @@ } .media { - //padding-top: 15px; + /* padding-top: 15px; */ overflow: visible; } @@ -594,8 +594,8 @@ width: 160px; position: fixed; z-index: 1; - //top: 0; - //left: 0; + /* top: 0; + left: 0; */ overflow-x: hidden; padding-top: 20px; padding-right: 25px; @@ -826,7 +826,7 @@ input[type="submit"] { width: 100%; padding: 20px; z-index: 5000; - pointer-events: none; // Prevent this div from blocking links underneath + pointer-events: none; /* Prevent this div from blocking links underneath */ } .alert { @@ -936,4 +936,15 @@ input[type="submit"] { input[type="date"].form-control, input[type="time"].form-control, input[type="datetime-local"].form-control, input[type="month"].form-control { line-height: unset; -} \ No newline at end of file +} + +.clip-btn { + font-size: 10px; + padding: 0px 6px; + color: var(--label-grey); + background: none; +} + +.clip-btn:hover { + background: var(--label-grey); +} diff --git a/InvenTree/InvenTree/static/script/clipboard.min.js b/InvenTree/InvenTree/static/script/clipboard.min.js new file mode 100644 index 0000000000..95f55d7b0c --- /dev/null +++ b/InvenTree/InvenTree/static/script/clipboard.min.js @@ -0,0 +1,7 @@ +/*! + * clipboard.js v2.0.8 + * https://clipboardjs.com/ + * + * Licensed MIT © Zeno Rocha + */ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return n={134:function(t,e,n){"use strict";n.d(e,{default:function(){return r}});var e=n(279),i=n.n(e),e=n(370),a=n.n(e),e=n(817),o=n.n(e);function c(t){return(c="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function u(t,e){for(var n=0;n {% trans "Part name" %} - {{ part.name }} + {{ part.name }}{% include "clip.html"%} {% if part.IPN %} {% trans "IPN" %} - {{ part.IPN }} + {{ part.IPN }}{% include "clip.html"%} {% endif %} {% if part.revision %} {% trans "Revision" %} - {{ part.revision }} + {{ part.revision }}{% include "clip.html"%} {% endif %} {% if part.trackable %} @@ -42,7 +42,7 @@ {% trans "Latest Serial Number" %} {% if part.getLatestSerialNumber %} - {{ part.getLatestSerialNumber }} + {{ part.getLatestSerialNumber }}{% include "clip.html"%} {% else %} {% trans "No serial numbers recorded" %} {% endif %} @@ -52,7 +52,7 @@ {% trans "Description" %} - {{ part.description }} + {{ part.description }}{% include "clip.html"%} {% if part.variant_of %} @@ -96,7 +96,7 @@ {% trans "Default Supplier" %} - {{ part.default_supplier.supplier.name }} | {{ part.default_supplier.SKU }} + {{ part.default_supplier.supplier.name }} | {{ part.default_supplier.SKU }}{% include "clip.html"%} {% endif %} diff --git a/InvenTree/stock/admin.py b/InvenTree/stock/admin.py index 9fed3e53a4..f32fa008a0 100644 --- a/InvenTree/stock/admin.py +++ b/InvenTree/stock/admin.py @@ -130,7 +130,7 @@ class StockAttachmentAdmin(admin.ModelAdmin): class StockTrackingAdmin(ImportExportModelAdmin): - list_display = ('item', 'date', 'title') + list_display = ('item', 'date', 'label') class StockItemTestResultAdmin(admin.ModelAdmin): diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index b70b379e69..376d04f643 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -21,8 +21,11 @@ from .models import StockItemTestResult from part.models import Part, PartCategory from part.serializers import PartBriefSerializer -from company.models import SupplierPart -from company.serializers import SupplierPartSerializer +from company.models import Company, SupplierPart +from company.serializers import CompanySerializer, SupplierPartSerializer + +from order.models import PurchaseOrder +from order.serializers import POSerializer import common.settings import common.models @@ -97,6 +100,16 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView): return self.serializer_class(*args, **kwargs) + def update(self, request, *args, **kwargs): + """ + Record the user who updated the item + """ + + # TODO: Record the user! + # user = request.user + + return super().update(request, *args, **kwargs) + class StockFilter(FilterSet): """ FilterSet for advanced stock filtering. @@ -371,25 +384,26 @@ class StockList(generics.ListCreateAPIView): we can pre-fill the location automatically. """ + user = request.user + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + # TODO - Save the user who created this item item = serializer.save() # A location was *not* specified - try to infer it if 'location' not in request.data: - location = item.part.get_default_location() - - if location is not None: - item.location = location - item.save() + item.location = item.part.get_default_location() # An expiry date was *not* specified - try to infer it! if 'expiry_date' not in request.data: if item.part.default_expiry > 0: item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry) - item.save() + + # Finally, save the item + item.save(user=user) # Return a response headers = self.get_success_headers(serializer.data) @@ -965,7 +979,7 @@ class StockItemTestResultList(generics.ListCreateAPIView): test_result.save() -class StockTrackingList(generics.ListCreateAPIView): +class StockTrackingList(generics.ListAPIView): """ API endpoint for list view of StockItemTracking objects. StockItemTracking objects are read-only @@ -992,6 +1006,59 @@ class StockTrackingList(generics.ListCreateAPIView): return self.serializer_class(*args, **kwargs) + def list(self, request, *args, **kwargs): + + queryset = self.filter_queryset(self.get_queryset()) + + serializer = self.get_serializer(queryset, many=True) + + data = serializer.data + + # Attempt to add extra context information to the historical data + for item in data: + deltas = item['deltas'] + + # Add location detail + if 'location' in deltas: + try: + location = StockLocation.objects.get(pk=deltas['location']) + serializer = LocationSerializer(location) + deltas['location_detail'] = serializer.data + except: + pass + + # Add stockitem detail + if 'stockitem' in deltas: + try: + stockitem = StockItem.objects.get(pk=deltas['stockitem']) + serializer = StockItemSerializer(stockitem) + deltas['stockitem_detail'] = serializer.data + except: + pass + + # Add customer detail + if 'customer' in deltas: + try: + customer = Company.objects.get(pk=deltas['customer']) + serializer = CompanySerializer(customer) + deltas['customer_detail'] = serializer.data + except: + pass + + # Add purchaseorder detail + if 'purchaseorder' in deltas: + try: + order = PurchaseOrder.objects.get(pk=deltas['purchaseorder']) + serializer = POSerializer(order) + deltas['purchaseorder_detail'] = serializer.data + except: + pass + + if request.is_ajax(): + return JsonResponse(data, safe=False) + else: + return Response(data) + def create(self, request, *args, **kwargs): """ Create a new StockItemTracking object diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 3fb72ebe1e..92089623f9 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -393,6 +393,18 @@ class AdjustStockForm(forms.ModelForm): ] +class EditStockItemStatusForm(HelperForm): + """ + Simple form for editing StockItem status field + """ + + class Meta: + model = StockItem + fields = [ + 'status', + ] + + class EditStockItemForm(HelperForm): """ Form for editing a StockItem object. Note that not all fields can be edited here (even if they can be specified during creation. @@ -425,14 +437,15 @@ class EditStockItemForm(HelperForm): class TrackingEntryForm(HelperForm): - """ Form for creating / editing a StockItemTracking object. + """ + Form for creating / editing a StockItemTracking object. + + Note: 2021-05-11 - This form is not currently used - should delete? """ class Meta: model = StockItemTracking fields = [ - 'title', 'notes', - 'link', ] 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..0ab37250c8 --- /dev/null +++ b/InvenTree/stock/migrations/0061_auto_20210511_0911.py @@ -0,0 +1,219 @@ +# Generated by Django 3.2 on 2021-05-10 23:11 + +import re + +from django.db import migrations + +from InvenTree.status_codes import StockHistoryCode + + +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') + StockLocation = apps.get_model('stock', 'stocklocation') + + update_count = 0 + + locations = StockLocation.objects.all() + + for location in locations: + # Pre-calculate pathstring + # Note we cannot use the 'pathstring' function here as we don't have access to model functions! + + path = [location.name] + + loc = location + + while loc.parent: + loc = loc.parent + path = [loc.name] + path + + location._path = '/'.join(path) + + 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 idx, entry in enumerate(history): + + deltas = {} + updated = False + + q = entry.quantity + + if idx == 0 or not q == quantity: + + try: + deltas['quantity']: float(q) + updated = True + except: + print(f"WARNING: Error converting quantity '{q}'") + + + quantity = q + + # Try to "guess" the "type" of tracking entry, based on the title + title = entry.title.lower() + + tracking_type = None + + if 'completed build' in title: + tracking_type = StockHistoryCode.BUILD_OUTPUT_COMPLETED + + elif 'removed' in title and 'item' in title: + + if entry.notes.lower().startswith('split '): + tracking_type = StockHistoryCode.SPLIT_CHILD_ITEM + else: + tracking_type = StockHistoryCode.STOCK_REMOVE + + # Extract the number of removed items + result = re.search("^removed ([\d\.]+) items", title) + + if result: + + removed = result.groups()[0] + + try: + deltas['removed'] = float(removed) + + # Ensure that 'quantity' is stored too in this case + deltas['quantity'] = float(q) + except: + print(f"WARNING: Error converting removed quantity '{removed}'") + else: + print(f"Could not decode '{title}'") + + elif 'split from existing' in title: + tracking_type = StockHistoryCode.SPLIT_FROM_PARENT + + deltas['quantity'] = float(q) + + elif 'moved to' in title: + tracking_type = StockHistoryCode.STOCK_MOVE + + result = re.search('^Moved to (.*)( - )*(.*) \(from.*$', entry.title) + + if result: + # Legacy tracking entries recorded the location in multiple ways, because.. why not? + text = result.groups()[0] + + matches = set() + + for location in locations: + + # Direct match for pathstring + if text == location._path: + matches.add(location) + + # Direct match for name + if text == location.name: + matches.add(location) + + # Match for "name - description" + compare = f"{location.name} - {location.description}" + + if text == compare: + matches.add(location) + + # Match for "pathstring - description" + compare = f"{location._path} - {location.description}" + + if text == compare: + matches.add(location) + + if len(matches) == 1: + location = list(matches)[0] + + deltas['location'] = location.pk + + else: + print(f"No location match: '{text}'") + break + + elif 'created stock item' in title: + tracking_type = StockHistoryCode.CREATED + + elif 'add serial number' in title: + tracking_type = StockHistoryCode.ASSIGNED_SERIAL + + elif 'returned from customer' in title: + tracking_type = StockHistoryCode.RETURNED_FROM_CUSTOMER + + elif 'counted' in title: + tracking_type = StockHistoryCode.STOCK_COUNT + + elif 'added' in title: + tracking_type = StockHistoryCode.STOCK_ADD + + # Extract the number of added items + result = re.search("^added ([\d\.]+) items", title) + + if result: + + added = result.groups()[0] + + try: + deltas['added'] = float(added) + + # Ensure that 'quantity' is stored too in this case + deltas['quantity'] = float(q) + except: + print(f"WARNING: Error converting added quantity '{added}'") + + else: + print(f"Could not decode '{title}'") + + elif 'assigned to customer' in title: + tracking_type = StockHistoryCode.SENT_TO_CUSTOMER + + elif 'installed into stock item' in title: + tracking_type = StockHistoryCode.INSTALLED_INTO_ASSEMBLY + + elif 'uninstalled into location' in title: + tracking_type = StockHistoryCode.REMOVED_FROM_ASSEMBLY + + elif 'installed stock item' in title: + tracking_type = StockHistoryCode.INSTALLED_CHILD_ITEM + + elif 'received items' in title: + tracking_type = StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER + + if tracking_type is not None: + entry.tracking_type = tracking_type + updated = True + + if updated: + entry.deltas = deltas + entry.save() + update_count += 1 + + + print(f"\n==========================\nUpdated {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/migrations/0062_auto_20210511_2151.py b/InvenTree/stock/migrations/0062_auto_20210511_2151.py new file mode 100644 index 0000000000..18832819ff --- /dev/null +++ b/InvenTree/stock/migrations/0062_auto_20210511_2151.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2 on 2021-05-11 11:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0061_auto_20210511_0911'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitemtracking', + name='notes', + field=models.CharField(blank=True, help_text='Entry notes', max_length=512, null=True, verbose_name='Notes'), + ), + migrations.AlterField( + model_name='stockitemtracking', + name='title', + field=models.CharField(blank=True, help_text='Tracking entry title', max_length=250, null=True, verbose_name='Title'), + ), + ] diff --git a/InvenTree/stock/migrations/0063_auto_20210511_2343.py b/InvenTree/stock/migrations/0063_auto_20210511_2343.py new file mode 100644 index 0000000000..dc8a391cde --- /dev/null +++ b/InvenTree/stock/migrations/0063_auto_20210511_2343.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2 on 2021-05-11 13:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0062_auto_20210511_2151'), + ] + + operations = [ + migrations.RemoveField( + model_name='stockitemtracking', + name='link', + ), + migrations.RemoveField( + model_name='stockitemtracking', + name='quantity', + ), + migrations.RemoveField( + model_name='stockitemtracking', + name='system', + ), + migrations.RemoveField( + model_name='stockitemtracking', + name='title', + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 3aecfff2c2..28123ebc41 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 @@ -183,29 +183,61 @@ class StockItem(MPTTModel): self.validate_unique() self.clean() - if not self.pk: - # StockItem has not yet been saved - add_note = True - else: - # StockItem has already been saved - add_note = False - user = kwargs.pop('user', None) - add_note = add_note and kwargs.pop('note', True) + # If 'add_note = False' specified, then no tracking note will be added for item creation + add_note = kwargs.pop('add_note', True) + + notes = kwargs.pop('notes', '') + + if not self.pk: + # StockItem has not yet been saved + add_note = add_note and True + else: + # StockItem has already been saved + + # Check if "interesting" fields have been changed + # (we wish to record these as historical records) + + try: + old = StockItem.objects.get(pk=self.pk) + + deltas = {} + + # Status changed? + if not old.status == self.status: + deltas['status'] = self.status + + # TODO - Other interesting changes we are interested in... + + if add_note and len(deltas) > 0: + self.add_tracking_entry( + StockHistoryCode.EDITED, + user, + deltas=deltas, + notes=notes, + ) + + except (ValueError, StockItem.DoesNotExist): + pass + + add_note = False super(StockItem, self).save(*args, **kwargs) if add_note: - note = _('Created new stock item for {part}').format(part=str(self.part)) + tracking_info = { + 'status': self.status, + } - # This StockItem is being saved for the first time - self.addTransactionNote( - _('Created stock item'), + self.add_tracking_entry( + StockHistoryCode.CREATED, user, - note, - system=True + deltas=tracking_info, + notes=notes, + location=self.location, + quantity=float(self.quantity), ) @property @@ -610,31 +642,42 @@ 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 self.customer: + tracking_info['customer'] = self.customer.id + tracking_info['customer_name'] = self.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, + location=location ) self.customer = None self.location = location - self.sales_order = None self.save() @@ -788,18 +831,23 @@ 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() + deltas={ + 'stockitem': 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, + deltas={ + 'stockitem': stock_item.pk, + } ) @transaction.atomic @@ -820,11 +868,25 @@ 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, + ) + + tracking_info = { + 'stockitem': self.belongs_to.pk + } + + self.add_tracking_entry( + StockHistoryCode.REMOVED_FROM_ASSEMBLY, user, notes=notes, - url=self.get_absolute_url(), + deltas=tracking_info, + location=location, ) # Mark this stock item as *not* belonging to anyone @@ -833,19 +895,6 @@ class StockItem(MPTTModel): 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 +950,40 @@ 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='', **kwargs): + """ + 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( + # Has a location been specified? + location = kwargs.get('location', None) + + if location: + deltas['location'] = location.id + + # Quantity specified? + quantity = kwargs.get('quantity', None) + + if quantity: + deltas['quantity'] = float(quantity) + + 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, - link=url, - system=system + deltas=deltas, ) - track.save() + entry.save() @transaction.atomic def serializeStock(self, quantity, serials, user, notes='', location=None): @@ -930,7 +995,7 @@ class StockItem(MPTTModel): Args: quantity: Number of items to serialize (integer) - serials: List of serial numbers (list) + serials: List of serial numbers user: User object associated with action notes: Optional notes for tracking location: If specified, serialized items will be placed in the given location @@ -982,7 +1047,7 @@ class StockItem(MPTTModel): new_item.location = location # The item already has a transaction history, don't create a new note - new_item.save(user=user, note=False) + new_item.save(user=user, notes=notes) # Copy entire transaction history new_item.copyHistoryFrom(self) @@ -991,10 +1056,18 @@ 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, + }, + location=location + ) # 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 +1091,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 +1105,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 +1146,21 @@ 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, + }, + location=location, ) # 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 @@ -1131,18 +1210,17 @@ class StockItem(MPTTModel): return True - if self.location: - msg = _("Moved to {loc_new} (from {loc_old})").format(loc_new=str(location), loc_old=str(self.location)) - else: - msg = _('Moved to {loc_new}').format(loc_new=str(location)) - self.location = location - self.addTransactionNote( - msg, + tracking_info = {} + + self.add_tracking_entry( + StockHistoryCode.STOCK_MOVE, user, notes=notes, - system=True) + deltas=tracking_info, + location=location, + ) self.save() @@ -1202,13 +1280,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': float(self.quantity), + } ) return True @@ -1234,20 +1312,23 @@ 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': float(quantity), + 'quantity': float(self.quantity), + } ) return True @transaction.atomic def take_stock(self, quantity, user, notes=''): - """ Remove items from stock + """ + Remove items from stock """ # Cannot remove items from a serialized part @@ -1264,12 +1345,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': float(quantity), + 'quantity': float(self.quantity), + } + ) return True @@ -1527,44 +1611,58 @@ 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) + 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 - quantity: The StockItem quantity at this point in time + deltas: The changes associated with this history item """ 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') + def label(self): + + if self.tracking_type in StockHistoryCode.keys(): + return StockHistoryCode.label(self.tracking_type) + else: + return self.title + + 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')) - - 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')) + notes = models.CharField( + blank=True, null=True, + max_length=512, + verbose_name=_('Notes'), + help_text=_('Entry notes') + ) user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True) - system = models.BooleanField(default=False) - - quantity = models.DecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, verbose_name=_('Quantity')) - - # TODO - # image = models.ImageField(upload_to=func, max_length=255, null=True, blank=True) - - # TODO - # file = models.FileField() + deltas = models.JSONField(null=True, blank=True) def rename_stock_item_test_result_attachment(instance, filename): diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 4991a44e6f..9bcdc5182e 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -349,32 +349,32 @@ class StockTrackingSerializer(InvenTreeModelSerializer): if user_detail is not True: self.fields.pop('user_detail') - url = serializers.CharField(source='get_absolute_url', read_only=True) + label = serializers.CharField(read_only=True) item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True) user_detail = UserSerializerBrief(source='user', many=False, read_only=True) + deltas = serializers.JSONField(read_only=True) + class Meta: model = StockItemTracking fields = [ 'pk', - 'url', 'item', 'item_detail', 'date', - 'title', + 'deltas', + 'label', 'notes', - 'link', - 'quantity', + 'tracking_type', 'user', 'user_detail', - 'system', ] read_only_fields = [ 'date', 'user', - 'system', - 'quantity', + 'label', + 'tracking_type', ] diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index c7e0dc15dd..54d6b12e4f 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -94,7 +94,13 @@ {% if item.is_expired %} {% trans "Expired" %} {% else %} + {% if roles.stock.change %} + + {% endif %} {% stock_status_label item.status large=True %} + {% if roles.stock.change %} + + {% endif %} {% if item.is_stale %} {% trans "Stale" %} {% endif %} @@ -453,6 +459,7 @@ $("#print-label").click(function() { printStockItemLabels([{{ item.pk }}]); }); +{% if roles.stock.change %} $("#stock-duplicate").click(function() { createNewStockItem({ follow: true, @@ -472,6 +479,18 @@ $("#stock-edit").click(function () { ); }); +$('#stock-edit-status').click(function () { + launchModalForm( + "{% url 'stock-item-edit-status' item.id %}", + { + reload: true, + submit_text: '{% trans "Save" %}', + } + ); +}); + +{% endif %} + $("#show-qr-code").click(function() { launchModalForm("{% url 'stock-item-qr' item.id %}", { diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index 08fa727547..6bc15b3505 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -5,6 +5,8 @@ from django.core.exceptions import ValidationError import datetime +from InvenTree.status_codes import StockHistoryCode + from .models import StockLocation, StockItem, StockItemTracking from .models import StockItemTestResult @@ -217,7 +219,7 @@ class StockTest(TestCase): track = StockItemTracking.objects.filter(item=it).latest('id') self.assertEqual(track.item, it) - self.assertIn('Moved to', track.title) + self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_MOVE) self.assertEqual(track.notes, 'Moved to the bathroom') def test_self_move(self): @@ -284,8 +286,7 @@ class StockTest(TestCase): # Check that a tracking item was added track = StockItemTracking.objects.filter(item=it).latest('id') - self.assertIn('Counted', track.title) - self.assertIn('items', track.title) + self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_COUNT) self.assertIn('Counted items', track.notes) n = it.tracking_info.count() @@ -304,7 +305,7 @@ class StockTest(TestCase): # Check that a tracking item was added track = StockItemTracking.objects.filter(item=it).latest('id') - self.assertIn('Added', track.title) + self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_ADD) self.assertIn('Added some items', track.notes) self.assertFalse(it.add_stock(-10, None)) @@ -319,7 +320,7 @@ class StockTest(TestCase): # Check that a tracking item was added track = StockItemTracking.objects.filter(item=it).latest('id') - self.assertIn('Removed', track.title) + self.assertEqual(track.tracking_type, StockHistoryCode.STOCK_REMOVE) self.assertIn('Removed some items', track.notes) self.assertTrue(it.has_tracking_info) diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index fe5472003f..dbdbdda317 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -4,7 +4,7 @@ URL lookup for Stock app from django.conf.urls import url, include -from . import views +from stock import views location_urls = [ @@ -24,6 +24,7 @@ location_urls = [ ] stock_item_detail_urls = [ + url(r'^edit_status/', views.StockItemEditStatus.as_view(), name='stock-item-edit-status'), url(r'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'), url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'), url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 0984405055..a13f885e80 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -1212,6 +1212,27 @@ class StockAdjust(AjaxView, FormMixin): return _("Deleted {n} stock items").format(n=count) +class StockItemEditStatus(AjaxUpdateView): + """ + View for editing stock item status field + """ + + model = StockItem + form_class = StockForms.EditStockItemStatusForm + ajax_form_title = _('Edit Stock Item Status') + + def save(self, object, form, **kwargs): + """ + Override the save method, to track the user who updated the model + """ + + item = form.save(commit=False) + + item.save(user=self.request.user) + + return item + + class StockItemEdit(AjaxUpdateView): """ View for editing details of a single StockItem @@ -1321,6 +1342,17 @@ class StockItemEdit(AjaxUpdateView): if not owner and not self.request.user.is_superuser: form.add_error('owner', _('Owner is required (ownership control is enabled)')) + def save(self, object, form, **kwargs): + """ + Override the save method, to track the user who updated the model + """ + + item = form.save(commit=False) + + item.save(user=self.request.user) + + return item + class StockItemConvert(AjaxUpdateView): """ diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index 04fe2a7899..5133d57839 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -133,11 +133,14 @@ + + + diff --git a/InvenTree/templates/clip.html b/InvenTree/templates/clip.html new file mode 100644 index 0000000000..a56ece838c --- /dev/null +++ b/InvenTree/templates/clip.html @@ -0,0 +1,5 @@ +{% load i18n %} + + + + \ No newline at end of file diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js index 4e5d78d8cd..a0601aeb13 100644 --- a/InvenTree/templates/js/stock.js +++ b/InvenTree/templates/js/stock.js @@ -976,42 +976,28 @@ function loadStockLocationTable(table, options) { function loadStockTrackingTable(table, options) { - var cols = [ - { - field: 'pk', - visible: false, - }, - { - field: 'date', - title: '{% trans "Date" %}', - sortable: true, - formatter: function(value, row, index, field) { - var m = moment(value); - if (m.isValid()) { - var html = m.format('dddd MMMM Do YYYY'); // + '
' + m.format('h:mm a'); - return html; - } + var cols = []; - return 'N/A'; - } - }, - ]; + // Date + cols.push({ + field: 'date', + title: '{% trans "Date" %}', + sortable: true, + formatter: function(value, row, index, field) { + var m = moment(value); - // If enabled, provide a link to the referenced StockItem - if (options.partColumn) { - cols.push({ - field: 'item', - title: '{% trans "Stock Item" %}', - sortable: true, - formatter: function(value, row, index, field) { - return renderLink(value.part_name, value.url); + if (m.isValid()) { + var html = m.format('dddd MMMM Do YYYY'); // + '
' + m.format('h:mm a'); + return html; } - }); - } + + return '{% trans "Invalid date" %}'; + } + }); // Stock transaction description cols.push({ - field: 'title', + field: 'label', title: '{% trans "Description" %}', formatter: function(value, row, index, field) { var html = "" + value + ""; @@ -1020,20 +1006,139 @@ function loadStockTrackingTable(table, options) { html += "
" + row.notes + ""; } - if (row.link) { - html += "
" + row.link + ""; - } - return html; } }); + // Stock transaction details cols.push({ - field: 'quantity', - title: '{% trans "Quantity" %}', - formatter: function(value, row, index, field) { - return parseFloat(value); - }, + field: 'deltas', + title: '{% trans "Details" %}', + formatter: function(details, row, index, field) { + var html = ``; + + // Location information + if (details.location) { + + html += ``; + + html += ''; + } + + // Purchase Order Information + if (details.purchaseorder) { + + html += `'; + } + + // Customer information + if (details.customer) { + + html += `'; + } + + // Stockitem information + if (details.stockitem) { + html += ''; + } + + // Status information + if (details.status) { + html += `'; + + } + + // Quantity information + if (details.added) { + html += ''; + + html += ``; + + html += ''; + } + + if (details.removed) { + html += ''; + + html += ``; + + html += ''; + } + + if (details.quantity) { + html += ''; + + html += ``; + + html += ''; + } + + html += '
{% trans "Location" %}'; + + if (details.location_detail) { + // A valid location is provided + + html += renderLink( + details.location_detail.pathstring, + details.location_detail.url, + ); + } else { + // An invalid location (may have been deleted?) + html += `{% trans "Location no longer exists" %}`; + } + + html += '
{% trans "Purchase Order" %}`; + + html += ''; + + if (details.purchaseorder_detail) { + html += renderLink( + details.purchaseorder_detail.reference, + `/order/purchase-order/${details.purchaseorder}/` + ); + } else { + html += `{% trans "Purchase order no longer exists" %}`; + } + + html += '
{% trans "Customer" %}`; + + html += ''; + + if (details.customer_detail) { + html += renderLink( + details.customer_detail.name, + details.customer_detail.url + ); + } else { + html += `{% trans "Customer no longer exists" %}`; + } + + html += '
{% trans "Stock Item" %}'; + + html += ''; + + if (details.stockitem_detail) { + html += renderLink( + details.stockitem, + `/stock/item/${details.stockitem}/` + ); + } else { + html += `{% trans "Stock item no longer exists" %}`; + } + + html += '
{% trans "Status" %}`; + + html += ''; + html += stockStatusDisplay( + details.status, + { + classes: 'float-right', + } + ); + html += '
{% trans "Added" %}${details.added}
{% trans "Removed" %}${details.removed}
{% trans "Quantity" %}${details.quantity}
'; + + return html; + } }); cols.push({ @@ -1052,11 +1157,13 @@ function loadStockTrackingTable(table, options) { } }); + /* + // 2021-05-11 - Ability to edit or delete StockItemTracking entries is now removed cols.push({ sortable: false, formatter: function(value, row, index, field) { // Manually created entries can be edited or deleted - if (!row.system) { + if (false && !row.system) { var bEdit = ""; var bDel = ""; @@ -1066,6 +1173,7 @@ function loadStockTrackingTable(table, options) { } } }); + */ table.inventreeTable({ method: 'get', diff --git a/InvenTree/templates/js/table_filters.js b/InvenTree/templates/js/table_filters.js index 775f0d9803..5f516e9419 100644 --- a/InvenTree/templates/js/table_filters.js +++ b/InvenTree/templates/js/table_filters.js @@ -3,6 +3,7 @@ {% load inventree_extras %} {% include "status_codes.html" with label='stock' options=StockStatus.list %} +{% include "status_codes.html" with label='stockHistory' options=StockHistoryCode.list %} {% include "status_codes.html" with label='build' options=BuildStatus.list %} {% include "status_codes.html" with label='purchaseOrder' options=PurchaseOrderStatus.list %} {% include "status_codes.html" with label='salesOrder' options=SalesOrderStatus.list %} diff --git a/InvenTree/templates/status_codes.html b/InvenTree/templates/status_codes.html index f032f97309..e7bc2e951c 100644 --- a/InvenTree/templates/status_codes.html +++ b/InvenTree/templates/status_codes.html @@ -14,7 +14,7 @@ var {{ label }}Codes = { * Uses the values specified in "status_codes.py" * This function is generated by the "status_codes.html" template */ -function {{ label }}StatusDisplay(key) { +function {{ label }}StatusDisplay(key, options={}) { key = String(key); @@ -31,5 +31,11 @@ function {{ label }}StatusDisplay(key) { label = ''; } - return `${value}`; + var classes = `label ${label}`; + + if (options.classes) { + classes += ' ' + options.classes; + } + + return `${value}`; }