diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 887b443544..84409226c2 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -94,6 +94,18 @@ def MakeBarcode(object_type, object_id, object_url, data={}): return json.dumps(data, sort_keys=True) +def GetExportFormats(): + """ Return a list of allowable file formats for exporting data """ + + return [ + 'csv', + 'tsv', + 'xls', + 'xlsx', + 'json', + ] + + def DownloadFile(data, filename, content_type='application/text'): """ Create a dynamic file for the user to download. diff --git a/InvenTree/InvenTree/static/script/inventree/stock.js b/InvenTree/InvenTree/static/script/inventree/stock.js index e4fa188598..ae07be1dd3 100644 --- a/InvenTree/InvenTree/static/script/inventree/stock.js +++ b/InvenTree/InvenTree/static/script/inventree/stock.js @@ -76,12 +76,14 @@ function loadStockTable(table, options) { } else if (field == 'quantity') { var stock = 0; + var items = 0; data.forEach(function(item) { stock += item.quantity; + items += 1; }); - return stock; + return stock + " (" + items + " items)"; } else if (field == 'batch') { var batches = []; diff --git a/InvenTree/InvenTree/status_codes.py b/InvenTree/InvenTree/status_codes.py index b7b50a58f7..f772943ef0 100644 --- a/InvenTree/InvenTree/status_codes.py +++ b/InvenTree/InvenTree/status_codes.py @@ -10,7 +10,7 @@ class StatusCode: @classmethod def label(cls, value): """ Return the status code label associated with the provided value """ - return cls.options.get(value, '') + return cls.options.get(value, value) class OrderStatus(StatusCode): diff --git a/InvenTree/company/templates/company/detail_stock.html b/InvenTree/company/templates/company/detail_stock.html index 95c7d1f6d1..43966810b8 100644 --- a/InvenTree/company/templates/company/detail_stock.html +++ b/InvenTree/company/templates/company/detail_stock.html @@ -27,4 +27,18 @@ ] }); + $("#stock-export").click(function() { + launchModalForm("{% url 'stock-export-options' %}", { + submit_text: "Export", + success: function(response) { + var url = "{% url 'stock-export' %}"; + + url += "?format=" + response.format; + url += "&supplier={{ company.id }}"; + + location.href = url; + }, + }); + }); + {% endblock %} \ No newline at end of file diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index ad8a4c0918..9408247fd6 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -247,6 +247,7 @@ class PurchaseOrder(Order): if line.part: stock = StockItem( part=line.part.part, + supplier_part=line.part, location=location, quantity=quantity, purchase_order=self) diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index 575dd4a74b..2ddbdc3052 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -25,6 +25,27 @@ InvenTree | {{ order }} {% if order.URL %} {{ order.URL }} {% endif %} +

+

+
+ + + {% if order.status == OrderStatus.PENDING and order.lines.count > 0 %} + + {% elif order.status == OrderStatus.PLACED %} + + {% endif %} +
+
+

@@ -65,13 +86,6 @@ InvenTree | {{ order }} {% if order.status == OrderStatus.PENDING %} {% endif %} - - {% if order.status == OrderStatus.PENDING and order.lines.count > 0 %} - - {% elif order.status == OrderStatus.PLACED %} - - {% endif %} -

Order Items

diff --git a/InvenTree/order/templates/order/purchase_orders.html b/InvenTree/order/templates/order/purchase_orders.html index 78649ca6de..0e93c6d900 100644 --- a/InvenTree/order/templates/order/purchase_orders.html +++ b/InvenTree/order/templates/order/purchase_orders.html @@ -27,7 +27,7 @@ InvenTree | Purchase Orders $("#po-create").click(function() { launchModalForm("{% url 'purchase-order-create' %}", { - reload: true, + follow: true, } ); }); diff --git a/InvenTree/part/templates/part/stock.html b/InvenTree/part/templates/part/stock.html index a41d5fd32a..6050137b69 100644 --- a/InvenTree/part/templates/part/stock.html +++ b/InvenTree/part/templates/part/stock.html @@ -47,6 +47,21 @@ url: "{% url 'api-stock-list' %}", }); + $("#stock-export").click(function() { + launchModalForm("{% url 'stock-export-options' %}", { + submit_text: "Export", + success: function(response) { + var url = "{% url 'stock-export' %}"; + + url += "?format=" + response.format; + url += "&cascade=" + response.cascade; + url += "&part={{ part.id }}"; + + location.href = url; + }, + }); + }); + $('#item-create').click(function () { launchModalForm("{% url 'stock-item-create' %}", { reload: true, diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 6ad25ff279..3a34e0416d 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -9,6 +9,7 @@ from django import forms from django.forms.utils import ErrorDict from django.utils.translation import ugettext as _ +from InvenTree.helpers import GetExportFormats from InvenTree.forms import HelperForm from .models import StockLocation, StockItem, StockItemTracking @@ -96,6 +97,33 @@ class SerializeStockForm(forms.ModelForm): ] +class ExportOptionsForm(HelperForm): + """ Form for selecting stock export options """ + + file_format = forms.ChoiceField(label=_('File Format'), help_text=_('Select output file format')) + + include_sublocations = forms.BooleanField(required=False, initial=True, help_text=_("Include stock items in sub locations")) + + class Meta: + model = StockLocation + fields = [ + 'file_format', + 'include_sublocations', + ] + + def get_format_choices(self): + """ File format choices """ + + choices = [(x, x.upper()) for x in GetExportFormats()] + + return choices + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['file_format'].choices = self.get_format_choices() + + class AdjustStockForm(forms.ModelForm): """ Form for performing simple stock adjustments. diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 30adba9336..6a0d05cfcc 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -67,6 +67,24 @@ sessionStorage.removeItem('inventree-show-part-locations'); }); + $("#stock-export").click(function() { + launchModalForm("{% url 'stock-export-options' %}", { + submit_text: "Export", + success: function(response) { + var url = "{% url 'stock-export' %}"; + + url += "?format=" + response.format; + url += "&cascade=" + response.cascade; + + {% if location %} + url += "&location={{ location.id }}"; + {% endif %} + + location.href = url; + } + }); + }); + $('#location-create').click(function () { launchModalForm("{% url 'stock-location-create' %}", { diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 76fbaae669..fa849211f3 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -51,6 +51,9 @@ stock_urls = [ url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'), + url(r'^export-options/?', views.StockExportOptions.as_view(), name='stock-export-options'), + url(r'^export/?', views.StockExport.as_view(), name='stock-export'), + # Individual stock items url(r'^item/(?P\d+)/', include(stock_item_detail_urls)), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 1431cb3693..29b5c5d6bb 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -18,10 +18,14 @@ from InvenTree.views import AjaxView from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView from InvenTree.views import QRCodeView -from InvenTree.helpers import str2bool +from InvenTree.status_codes import StockStatus +from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats from InvenTree.helpers import ExtractSerialNumbers from datetime import datetime +import tablib + +from company.models import Company from part.models import Part from .models import StockItem, StockLocation, StockItemTracking @@ -31,6 +35,7 @@ from .forms import EditStockItemForm from .forms import AdjustStockForm from .forms import TrackingEntryForm from .forms import SerializeStockForm +from .forms import ExportOptionsForm class StockIndex(ListView): @@ -119,6 +124,178 @@ class StockLocationQRCode(QRCodeView): return None +class StockExportOptions(AjaxView): + """ Form for selecting StockExport options """ + + model = StockLocation + ajax_form_title = 'Stock Export Options' + form_class = ExportOptionsForm + + def post(self, request, *args, **kwargs): + + self.request = request + + fmt = request.POST.get('file_format', 'csv').lower() + cascade = str2bool(request.POST.get('include_sublocations', False)) + + # Format a URL to redirect to + url = reverse('stock-export') + + url += '?format=' + fmt + url += '&cascade=' + str(cascade) + + data = { + 'form_valid': True, + 'format': fmt, + 'cascade': cascade + } + + return self.renderJsonResponse(self.request, self.form_class(), data=data) + + def get(self, request, *args, **kwargs): + return self.renderJsonResponse(request, self.form_class()) + + +class StockExport(AjaxView): + """ Export stock data from a particular location. + Returns a file containing stock information for that location. + """ + + model = StockItem + + def get(self, request, *args, **kwargs): + + export_format = request.GET.get('format', 'csv').lower() + + # Check if a particular location was specified + loc_id = request.GET.get('location', None) + location = None + + if loc_id: + try: + location = StockLocation.objects.get(pk=loc_id) + except (ValueError, StockLocation.DoesNotExist): + pass + + # Check if a particular supplier was specified + sup_id = request.GET.get('supplier', None) + supplier = None + + if sup_id: + try: + supplier = Company.objects.get(pk=sup_id) + except (ValueError, Company.DoesNotExist): + pass + + # Check if a particular part was specified + part_id = request.GET.get('part', None) + part = None + + if part_id: + try: + part = Part.objects.get(pk=part_id) + except (ValueError, Part.DoesNotExist): + pass + + if export_format not in GetExportFormats(): + export_format = 'csv' + + filename = 'InvenTree_Stocktake_{date}.{fmt}'.format( + date=datetime.now().strftime("%d-%b-%Y"), + fmt=export_format + ) + + if location: + # CHeck if locations should be cascading + cascade = str2bool(request.GET.get('cascade', True)) + stock_items = location.get_stock_items(cascade) + else: + cascade = True + stock_items = StockItem.objects.all() + + if part: + stock_items = stock_items.filter(part=part) + + if supplier: + stock_items = stock_items.filter(supplier_part__supplier=supplier) + + # Filter out stock items that are not 'in stock' + stock_items = stock_items.filter(customer=None) + stock_items = stock_items.filter(belongs_to=None) + + # Column headers + headers = [ + _('Stock ID'), + _('Part ID'), + _('Part'), + _('Supplier Part ID'), + _('Supplier ID'), + _('Supplier'), + _('Location ID'), + _('Location'), + _('Quantity'), + _('Batch'), + _('Serial'), + _('Status'), + _('Notes'), + _('Review Needed'), + _('Last Updated'), + _('Last Stocktake'), + _('Purchase Order ID'), + _('Build ID'), + ] + + data = tablib.Dataset(headers=headers) + + for item in stock_items: + line = [] + + line.append(item.pk) + line.append(item.part.pk) + line.append(item.part.full_name) + + if item.supplier_part: + line.append(item.supplier_part.pk) + line.append(item.supplier_part.supplier.pk) + line.append(item.supplier_part.supplier.name) + else: + line.append('') + line.append('') + line.append('') + + if item.location: + line.append(item.location.pk) + line.append(item.location.name) + else: + line.append('') + line.append('') + + line.append(item.quantity) + line.append(item.batch) + line.append(item.serial) + line.append(StockStatus.label(item.status)) + line.append(item.notes) + line.append(item.review_needed) + line.append(item.updated) + line.append(item.stocktake_date) + + if item.purchase_order: + line.append(item.purchase_order.pk) + else: + line.append('') + + if item.build: + line.append(item.build.pk) + else: + line.append('') + + data.append(line) + + filedata = data.export(export_format) + + return DownloadFile(filedata, filename) + + class StockItemQRCode(QRCodeView): """ View for displaying a QR code for a StockItem object """ diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html index e53daa4718..5b707eab24 100644 --- a/InvenTree/templates/stock_table.html +++ b/InvenTree/templates/stock_table.html @@ -1,5 +1,6 @@
+ {% if not part or part.is_template == False %} {% endif %} diff --git a/Makefile b/Makefile index 38446635da..f6b054dab0 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ test: # Run code coverage coverage: python3 InvenTree/manage.py check - coverage run InvenTree/manage.py test build common company order part stock InvenTree + coverage run InvenTree/manage.py test build common company order part stock InvenTree coverage html # Install packages required to generate code docs diff --git a/docs/modules.rst b/docs/modules.rst index 06078ad6e2..6509965972 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -9,6 +9,7 @@ InvenTree Modules docs/InvenTree/index docs/build/index + docs/common/index docs/company/index docs/part/index docs/order/index @@ -18,6 +19,7 @@ The InvenTree Django ecosystem provides the following 'apps' for core functional * `InvenTree `_ - High level management functions * `Build `_ - Part build projects +* `Common `_ - Common modules used by various apps * `Company `_ - Company management (suppliers / customers) * `Part `_ - Part management * `Order `_ - Order management