diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 06336e136a..457ad3bd28 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -4,7 +4,7 @@ Provides information on the current InvenTree version import subprocess -INVENTREE_SW_VERSION = "0.0.1" +INVENTREE_SW_VERSION = "0.0.2" def inventreeVersion(): @@ -14,6 +14,8 @@ def inventreeVersion(): def inventreeCommitHash(): """ Returns the git commit hash for the running codebase """ + + # TODO - This doesn't seem to work when running under gunicorn. Why is this?! commit = str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip() return commit diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 282541d19f..15f93c9133 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -170,7 +170,7 @@ class AjaxView(AjaxMixin, View): """ def post(self, request, *args, **kwargs): - return JsonResponse('', safe=False) + return self.renderJsonResponse(request) def get(self, request, *args, **kwargs): diff --git a/InvenTree/build/templates/build/allocate.html b/InvenTree/build/templates/build/allocate.html index 3a024c8255..3471b1283a 100644 --- a/InvenTree/build/templates/build/allocate.html +++ b/InvenTree/build/templates/build/allocate.html @@ -62,12 +62,23 @@ InvenTree | Allocate Parts {% else %} - $("#build-list").bootstrapTable({}); + $("#build-list").bootstrapTable({ + search: true, + sortable: true, + }); $("#btn-allocate").click(function() { location.href = "{% url 'build-allocate' build.id %}?edit=1"; }); + $("#btn-order-parts").click(function() { + launchModalForm("/order/purchase-order/order-parts/", { + data: { + build: {{ build.id }}, + }, + }); + }); + {% endif %} {% endblock %} diff --git a/InvenTree/build/templates/build/allocate_view.html b/InvenTree/build/templates/build/allocate_view.html index 49ee5d71c4..b37f079b9d 100644 --- a/InvenTree/build/templates/build/allocate_view.html +++ b/InvenTree/build/templates/build/allocate_view.html @@ -3,17 +3,19 @@
+
- + - - - + + + + @@ -27,6 +29,7 @@ + {% endfor %} diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 028546a6d4..0e7d73d294 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -275,6 +275,7 @@ class BuildDetail(DetailView): build = self.get_object() ctx['bom_price'] = build.part.get_price_info(build.quantity, buy=False) + ctx['BuildStatus'] = BuildStatus return ctx @@ -296,6 +297,7 @@ class BuildAllocate(DetailView): context['part'] = part context['bom_items'] = bom_items + context['BuildStatus'] = BuildStatus context['bom_price'] = build.part.get_price_info(build.quantity, buy=False) diff --git a/InvenTree/company/templates/company/detail_part.html b/InvenTree/company/templates/company/detail_part.html index 5e0a28541c..d0ec6ce66a 100644 --- a/InvenTree/company/templates/company/detail_part.html +++ b/InvenTree/company/templates/company/detail_part.html @@ -12,6 +12,7 @@ @@ -101,4 +102,20 @@ url: "{% url 'api-part-supplier-list' %}" }); + $("#multi-part-order").click(function() { + var selections = $("#part-table").bootstrapTable("getSelections"); + + var parts = []; + + selections.forEach(function(item) { + parts.push(item.part); + }); + + launchModalForm("/order/purchase-order/order-parts/", { + data: { + parts: parts, + }, + }); + }); + {% endblock %} \ No newline at end of file diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 08e9a6f740..c7942e3549 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -47,5 +47,5 @@ class EditPurchaseOrderLineItemForm(HelperForm): 'part', 'quantity', 'reference', - 'received' + 'notes', ] diff --git a/InvenTree/order/migrations/0010_purchaseorderlineitem_notes.py b/InvenTree/order/migrations/0010_purchaseorderlineitem_notes.py new file mode 100644 index 0000000000..a38a564041 --- /dev/null +++ b/InvenTree/order/migrations/0010_purchaseorderlineitem_notes.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.2 on 2019-06-13 11:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0009_auto_20190606_2133'), + ] + + operations = [ + migrations.AddField( + model_name='purchaseorderlineitem', + name='notes', + field=models.CharField(blank=True, help_text='Line item notes', max_length=500), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 7ca2e942fd..e2a0efc36d 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -6,10 +6,12 @@ Order model definitions from django.db import models from django.core.validators import MinValueValidator +from django.core.exceptions import ValidationError from django.contrib.auth.models import User from django.urls import reverse from django.utils.translation import ugettext as _ +import tablib from datetime import datetime from company.models import Company, SupplierPart @@ -98,9 +100,101 @@ class PurchaseOrder(Order): help_text=_('Company') ) + def export_to_file(self, **kwargs): + """ Export order information to external file """ + + file_format = kwargs.get('format', 'csv').lower() + + data = tablib.Dataset(headers=[ + 'Line', + 'Part', + 'Description', + 'Manufacturer', + 'MPN', + 'Order Code', + 'Quantity', + 'Received', + 'Reference', + 'Notes', + ]) + + idx = 0 + + for item in self.lines.all(): + + line = [] + + line.append(idx) + + if item.part: + line.append(item.part.part.name) + line.append(item.part.part.description) + + line.append(item.part.manufacturer) + line.append(item.part.MPN) + line.append(item.part.SKU) + + else: + line += [[] * 5] + + line.append(item.quantity) + line.append(item.received) + line.append(item.reference) + line.append(item.notes) + + idx += 1 + + data.append(line) + + return data.export(file_format) + def get_absolute_url(self): return reverse('purchase-order-detail', kwargs={'pk': self.id}) + def add_line_item(self, supplier_part, quantity, group=True, reference=''): + """ Add a new line item to this purchase order. + This function will check that: + + * The supplier part matches the supplier specified for this purchase order + * The quantity is greater than zero + + Args: + supplier_part - The supplier_part to add + quantity - The number of items to add + group - If True, this new quantity will be added to an existing line item for the same supplier_part (if it exists) + """ + + try: + quantity = int(quantity) + if quantity <= 0: + raise ValidationError({ + 'quantity': _("Quantity must be greater than zero")}) + except ValueError: + raise ValidationError({'quantity': _("Invalid quantity provided")}) + + if not supplier_part.supplier == self.supplier: + raise ValidationError({'supplier': _("Part supplier must match PO supplier")}) + + if group: + # Check if there is already a matching line item + matches = PurchaseOrderLineItem.objects.filter(part=supplier_part) + + if matches.count() > 0: + line = matches.first() + + line.quantity += quantity + line.save() + + return + + line = PurchaseOrderLineItem( + order=self, + part=supplier_part, + quantity=quantity, + reference=reference) + + line.save() + class OrderLineItem(models.Model): """ Abstract model for an order line item @@ -117,6 +211,8 @@ class OrderLineItem(models.Model): quantity = models.PositiveIntegerField(validators=[MinValueValidator(0)], default=1, help_text=_('Item quantity')) reference = models.CharField(max_length=100, blank=True, help_text=_('Line item reference')) + + notes = models.CharField(max_length=500, blank=True, help_text=_('Line item notes')) class PurchaseOrderLineItem(OrderLineItem): @@ -132,6 +228,13 @@ class PurchaseOrderLineItem(OrderLineItem): ('order', 'part') ) + def __str__(self): + return "{n} x {part} from {supplier} (for {po})".format( + n=self.quantity, + part=self.part.SKU if self.part else 'unknown part', + supplier=self.order.supplier.name, + po=self.order) + order = models.ForeignKey( PurchaseOrder, on_delete=models.CASCADE, related_name='lines', diff --git a/InvenTree/order/templates/order/order_wizard/select_parts.html b/InvenTree/order/templates/order/order_wizard/select_parts.html new file mode 100644 index 0000000000..f8ed6e2a93 --- /dev/null +++ b/InvenTree/order/templates/order/order_wizard/select_parts.html @@ -0,0 +1,74 @@ +{% extends "modal_form.html" %} + +{% block form %} + +

+ Step 1 of 2 - Select Parts +

+ +{% if parts|length > 0 %} + +{% else %} + +{% endif %} + + + {% csrf_token %} + {% load crispy_forms_tags %} + + + +
PartPart DescriptionAvailableRequiredAllocatedAvailableRequiredAllocatedOn Order
{{ item.part.total_stock }} {{ item.quantity }} {{ item.allocated }}{{ item.part.on_order }}
+ + + + + + + {% for part in parts %} + + + + + + + + {% endfor %} +
PartSelect SupplierQuantity
+ {% include "hover_image.html" with image=part.image hover=False %} + {{ part.full_name }} {{ part.description }} + + + +
+
+ +
+ {% if not part.order_supplier %} + Select a supplier for {{ part.name }} + {% endif %} +
+
+
+
+ +
+
+
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/order_wizard/select_pos.html b/InvenTree/order/templates/order/order_wizard/select_pos.html new file mode 100644 index 0000000000..f97ba95ee2 --- /dev/null +++ b/InvenTree/order/templates/order/order_wizard/select_pos.html @@ -0,0 +1,73 @@ +{% extends "modal_form.html" %} + +{% block form %} + +

+ Step 2 of 2 - Select Purchase Orders +

+ + + +
+ {% csrf_token %} + {% load crispy_forms_tags %} + + + + {% for supplier in suppliers %} + {% for item in supplier.order_items %} + + + {% endfor %} + {% endfor %} + + + + + + + + {% for supplier in suppliers %} + + + + + + + {% endfor %} + +
SupplierItemsSelect Purchase Order
+ {% include 'hover_image.html' with image=supplier.image hover=False %} + {{ supplier.name }} + {{ supplier.order_items|length }} + + +
+
+ +
+ {% if not supplier.selected_purchase_order %} + Select a purchase order for {{ supplier.name }} + {% endif %} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/po_lineitem_delete.html b/InvenTree/order/templates/order/po_lineitem_delete.html new file mode 100644 index 0000000000..3264fea625 --- /dev/null +++ b/InvenTree/order/templates/order/po_lineitem_delete.html @@ -0,0 +1,5 @@ +{% extends "modal_delete_form.html" %} + +{% block pre_form_content %} +Are you sure you wish to delete this line item? +{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/po_table.html b/InvenTree/order/templates/order/po_table.html index 0c1149470f..cfd4811d97 100644 --- a/InvenTree/order/templates/order/po_table.html +++ b/InvenTree/order/templates/order/po_table.html @@ -4,6 +4,7 @@ Order Reference Description Status + Items {% for order in orders %} @@ -11,6 +12,7 @@ {{ order }} {{ order.description }} {% include "order/order_status.html" %} + {{ order.lines.count }} {% endfor %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index b6751e044d..185eba4cad 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -64,6 +64,7 @@ InvenTree | {{ order }} {% if order.status == OrderStatus.PENDING and order.lines.count > 0 %} {% endif %} +

Order Items

@@ -79,20 +80,48 @@ InvenTree | {{ order }} Order Code Reference Quantity + {% if not order.status == OrderStatus.PENDING %} Received + {% endif %} + Note + {% if order.status == OrderStatus.PENDING %} + + {% endif %} {% for line in order.lines.all %} - {{ forloop.counter }} + + {{ forloop.counter }} + {% if line.part %} - {{ line.part.part.full_name }} + + {% include "hover_image.html" with image=line.part.part.image hover=True %} + {{ line.part.part.full_name }} + {{ line.part.SKU }} {% else %} Warning: Part has been deleted. {% endif %} {{ line.reference }} {{ line.quantity }} + {% if not order.status == OrderStatus.PENDING %} {{ line.received }} + {% endif %} + + {{ line.notes }} + + {% if order.status == OrderStatus.PENDING %} + +
+ + +
+ + {% endif %} {% endfor %} @@ -124,6 +153,10 @@ $("#edit-order").click(function() { ); }); +$("#export-order").click(function() { + location.href = "{% url 'purchase-order-export' order.id %}"; +}); + {% if order.status == OrderStatus.PENDING %} $('#new-po-line').click(function() { launchModalForm("{% url 'po-line-item-create' %}", @@ -151,4 +184,5 @@ $('#new-po-line').click(function() { $("#po-lines-table").bootstrapTable({ }); + {% endblock %} \ No newline at end of file diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index 20379edb76..695628bad0 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -14,18 +14,30 @@ purchase_order_detail_urls = [ url(r'^edit/?', views.PurchaseOrderEdit.as_view(), name='purchase-order-edit'), url(r'^issue/?', views.PurchaseOrderIssue.as_view(), name='purchase-order-issue'), + url(r'^export/?', views.PurchaseOrderExport.as_view(), name='purchase-order-export'), + url(r'^.*$', views.PurchaseOrderDetail.as_view(), name='purchase-order-detail'), ] +po_line_item_detail_urls = [ + + url(r'^edit/', views.POLineItemEdit.as_view(), name='po-line-item-edit'), + url(r'^delete/', views.POLineItemDelete.as_view(), name='po-line-item-delete'), +] + po_line_urls = [ url(r'^new/', views.POLineItemCreate.as_view(), name='po-line-item-create'), + + url(r'^(?P\d+)/', include(po_line_item_detail_urls)), ] purchase_order_urls = [ url(r'^new/', views.PurchaseOrderCreate.as_view(), name='purchase-order-create'), + url(r'^order-parts/', views.OrderParts.as_view(), name='order-parts'), + # Display detail view for a single purchase order url(r'^(?P\d+)/', include(purchase_order_detail_urls)), diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 718b26c98c..578021b2cb 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -5,20 +5,28 @@ Django views for interacting with Order app # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext as _ from django.views.generic import DetailView, ListView from django.forms import HiddenInput +import logging + from .models import PurchaseOrder, PurchaseOrderLineItem +from build.models import Build from company.models import Company, SupplierPart +from stock.models import StockItem +from part.models import Part from . import forms as order_forms -from InvenTree.views import AjaxCreateView, AjaxUpdateView -from InvenTree.helpers import str2bool +from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView +from InvenTree.helpers import DownloadFile, str2bool from InvenTree.status_codes import OrderStatus +logger = logging.getLogger(__name__) + class PurchaseOrderIndex(ListView): """ List view for all purchase orders """ @@ -135,6 +143,331 @@ class PurchaseOrderIssue(AjaxUpdateView): return self.renderJsonResponse(request, form, data) +class PurchaseOrderExport(AjaxView): + """ File download for a purchase order + + - File format can be optionally passed as a query param e.g. ?format=CSV + - Default file format is CSV + """ + + model = PurchaseOrder + + def get(self, request, *args, **kwargs): + + order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk']) + + export_format = request.GET.get('format', 'csv') + + filename = str(order) + '.' + export_format + + filedata = order.export_to_file(format=export_format) + + return DownloadFile(filedata, filename) + + +class OrderParts(AjaxView): + """ View for adding various SupplierPart items to a Purchase Order. + + SupplierParts can be selected from a variety of 'sources': + + - ?supplier_parts[]= -> Direct list of SupplierPart objects + - ?parts[]= -> List of base Part objects (user must then select supplier parts) + - ?stock[]= -> List of StockItem objects (user must select supplier parts) + - ?build= -> A Build object (user must select parts, then supplier parts) + + """ + + ajax_form_title = "Order Parts" + ajax_template_name = 'order/order_wizard/select_parts.html' + + # List of Parts we wish to order + parts = [] + suppliers = [] + + def get_context_data(self): + + ctx = {} + + ctx['parts'] = sorted(self.parts, key=lambda part: int(part.order_quantity), reverse=True) + ctx['suppliers'] = self.suppliers + + return ctx + + def get_suppliers(self): + """ Calculates a list of suppliers which the user will need to create POs for. + This is calculated AFTER the user finishes selecting the parts to order. + Crucially, get_parts() must be called before get_suppliers() + """ + + suppliers = {} + + for supplier in self.suppliers: + supplier.order_items = [] + suppliers[supplier.name] = supplier + + for part in self.parts: + supplier_part_id = part.order_supplier + + try: + supplier = SupplierPart.objects.get(pk=supplier_part_id).supplier + except SupplierPart.DoesNotExist: + continue + + if supplier.name not in suppliers: + supplier.order_items = [] + supplier.selected_purchase_order = None + suppliers[supplier.name] = supplier + + suppliers[supplier.name].order_items.append(part) + + self.suppliers = [suppliers[key] for key in suppliers.keys()] + + def get_parts(self): + """ Determine which parts the user wishes to order. + This is performed on the initial GET request. + """ + + self.parts = [] + + part_ids = set() + + # User has passed a list of stock items + if 'stock[]' in self.request.GET: + + stock_id_list = self.request.GET.getlist('stock[]') + + """ Get a list of all the parts associated with the stock items. + - Base part must be purchaseable. + - Return a set of corresponding Part IDs + """ + stock_items = StockItem.objects.filter( + part__purchaseable=True, + id__in=stock_id_list) + + for item in stock_items: + part_ids.add(item.part.id) + + # User has passed a single Part ID + elif 'part' in self.request.GET: + try: + part_id = self.request.GET.get('part') + part = Part.objects.get(id=part_id) + + part_ids.add(part.id) + + except Part.DoesNotExist: + pass + + # User has passed a list of part ID values + elif 'parts[]' in self.request.GET: + part_id_list = self.request.GET.getlist('parts[]') + + parts = Part.objects.filter( + purchaseable=True, + id__in=part_id_list) + + for part in parts: + part_ids.add(part.id) + + # User has provided a Build ID + elif 'build' in self.request.GET: + build_id = self.request.GET.get('build') + try: + build = Build.objects.get(id=build_id) + + parts = build.part.required_parts() + + for part in parts: + # If ordering from a Build page, ignore parts that we have enough of + if part.quantity_to_order <= 0: + continue + part_ids.add(part.id) + except Build.DoesNotExist: + pass + + # Create the list of parts + for id in part_ids: + try: + part = Part.objects.get(id=id) + # Pre-fill the 'order quantity' value + part.order_quantity = part.quantity_to_order + + default_supplier = part.get_default_supplier() + + if default_supplier: + part.order_supplier = default_supplier.id + else: + part.order_supplier = None + except Part.DoesNotExist: + continue + + self.parts.append(part) + + def get(self, request, *args, **kwargs): + + self.request = request + + self.get_parts() + + return self.renderJsonResponse(request) + + def post(self, request, *args, **kwargs): + """ Handle the POST action for part selection. + + - Validates each part / quantity / supplier / etc + + Part selection form contains the following fields for each part: + + - supplier- : The ID of the selected supplier + - quantity- : The quantity to add to the order + """ + + self.request = request + + self.parts = [] + self.suppliers = [] + + # Any errors for the part selection form? + part_errors = False + supplier_errors = False + + # Extract part information from the form + for item in self.request.POST: + + if item.startswith('part-supplier-'): + + pk = item.replace('part-supplier-', '') + + # Check that the part actually exists + try: + part = Part.objects.get(id=pk) + except (Part.DoesNotExist, ValueError): + continue + + supplier_part_id = self.request.POST[item] + + quantity = self.request.POST.get('part-quantity-' + str(pk), 0) + + # Ensure a valid supplier has been passed + try: + supplier_part = SupplierPart.objects.get(id=supplier_part_id) + except (SupplierPart.DoesNotExist, ValueError): + supplier_part = None + + # Ensure a valid quantity is passed + try: + quantity = int(quantity) + + # Eliminate lines where the quantity is zero + if quantity == 0: + continue + except ValueError: + quantity = part.quantity_to_order + + part.order_supplier = supplier_part.id if supplier_part else None + part.order_quantity = quantity + + self.parts.append(part) + + if supplier_part is None: + part_errors = True + + elif quantity < 0: + part_errors = True + + elif item.startswith('purchase-order-'): + # Which purchase order is selected for a given supplier? + pk = item.replace('purchase-order-', '') + + # Check that the Supplier actually exists + try: + supplier = Company.objects.get(id=pk) + except Company.DoesNotExist: + # Skip this item + continue + + purchase_order_id = self.request.POST[item] + + # Ensure that a valid purchase order has been passed + try: + purchase_order = PurchaseOrder.objects.get(pk=purchase_order_id) + except (PurchaseOrder.DoesNotExist, ValueError): + purchase_order = None + + supplier.selected_purchase_order = purchase_order.id if purchase_order else None + + self.suppliers.append(supplier) + + if supplier.selected_purchase_order is None: + supplier_errors = True + + form_step = request.POST.get('form_step') + + # Map parts to suppliers + self.get_suppliers() + + valid = False + + if form_step == 'select_parts': + # No errors? Proceed to PO selection form + if part_errors is False: + self.ajax_template_name = 'order/order_wizard/select_pos.html' + + else: + self.ajax_template_name = 'order/order_wizard/select_parts.html' + + elif form_step == 'select_purchase_orders': + + self.ajax_template_name = 'order/order_wizard/select_pos.html' + + valid = part_errors is False and supplier_errors is False + + # Form wizard is complete! Add items to purchase orders + if valid: + self.order_items() + + data = { + 'form_valid': valid, + 'success': 'Ordered {n} parts'.format(n=len(self.parts)) + } + + return self.renderJsonResponse(self.request, data=data) + + def order_items(self): + """ Add the selected items to the purchase orders. """ + + for supplier in self.suppliers: + + # Check that the purchase order does actually exist + try: + order = PurchaseOrder.objects.get(pk=supplier.selected_purchase_order) + except PurchaseOrder.DoesNotExist: + logger.critical('Could not add items to purchase order {po} - Order does not exist'.format(po=supplier.selected_purchase_order)) + continue + + for item in supplier.order_items: + + # Ensure that the quantity is valid + try: + quantity = int(item.order_quantity) + if quantity <= 0: + continue + except ValueError: + logger.warning("Did not add part to purchase order - incorrect quantity") + continue + + # Check that the supplier part does actually exist + try: + supplier_part = SupplierPart.objects.get(pk=item.order_supplier) + except SupplierPart.DoesNotExist: + logger.critical("Could not add part '{part}' to purchase order - selected supplier part '{sp}' does not exist.".format( + part=item, + sp=item.order_supplier)) + continue + + order.add_line_item(supplier_part, quantity) + + class POLineItemCreate(AjaxCreateView): """ AJAX view for creating a new PurchaseOrderLineItem object """ @@ -229,8 +562,32 @@ class POLineItemCreate(AjaxCreateView): class POLineItemEdit(AjaxUpdateView): + """ View for editing a PurchaseOrderLineItem object in a modal form. + """ model = PurchaseOrderLineItem form_class = order_forms.EditPurchaseOrderLineItemForm ajax_template_name = 'modal_form.html' - ajax_form_action = 'Edit Line Item' + ajax_form_title = 'Edit Line Item' + + def get_form(self): + form = super().get_form() + + # Prevent user from editing order once line item is assigned + form.fields.pop('order') + + return form + + +class POLineItemDelete(AjaxDeleteView): + """ View for deleting a PurchaseOrderLineItem object in a modal form + """ + + model = PurchaseOrderLineItem + ajax_form_title = 'Delete Line Item' + ajax_template_name = 'order/po_lineitem_delete.html' + + def get_data(self): + return { + 'danger': 'Deleted line item', + } diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 67a82a9504..38acae3360 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -339,7 +339,7 @@ class Part(models.Model): """ if self.default_supplier: - return self.default_suppliers + return self.default_supplier if self.supplier_count == 1: return self.supplier_parts.first() @@ -410,6 +410,26 @@ class Part(models.Model): return max(total, 0) + @property + def quantity_to_order(self): + """ Return the quantity needing to be ordered for this part. """ + + required = -1 * self.net_stock + return max(required, 0) + + @property + def net_stock(self): + """ Return the 'net' stock. It takes into account: + + - Stock on hand (total_stock) + - Stock on order (on_order) + - Stock allocated (allocation_count) + + This number (unlike 'available_stock') can be negative. + """ + + return self.total_stock - self.allocation_count + self.on_order + def isStarredBy(self, user): """ Return True if this part has been starred by a particular user """ diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 224e41cd97..37f5f1db7a 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -65,6 +65,8 @@ class PartSerializer(serializers.ModelSerializer): image_url = serializers.CharField(source='get_image_url', read_only=True) category_name = serializers.CharField(source='category_path', read_only=True) + allocated_stock = serializers.IntegerField(source='allocation_count', read_only=True) + @staticmethod def setup_eager_loading(queryset): queryset = queryset.prefetch_related('category') @@ -91,7 +93,8 @@ class PartSerializer(serializers.ModelSerializer): 'keywords', 'URL', 'total_stock', - # 'available_stock', + 'allocated_stock', + 'on_order', 'units', 'trackable', 'assembly', diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 2d76b08ac9..bbe449d217 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -49,6 +49,7 @@ diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index b536a5542e..fe357a6bdc 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -15,6 +15,9 @@ diff --git a/setup.cfg b/setup.cfg index 697f6d1f02..d6e7ca056a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,6 +3,8 @@ ignore = # - W293 - blank lines contain whitespace W293, # - E501 - line too long (82 characters) - E501 + E501, + # - C901 - function is too complex + C901, exclude = .git,__pycache__,*/migrations/* max-complexity = 20