diff --git a/InvenTree/InvenTree/management/commands/rebuild_models.py b/InvenTree/InvenTree/management/commands/rebuild_models.py new file mode 100644 index 0000000000..2a60da9365 --- /dev/null +++ b/InvenTree/InvenTree/management/commands/rebuild_models.py @@ -0,0 +1,60 @@ +""" +Custom management command to rebuild all MPTT models + +- This is crucial after importing any fixtures, etc +""" + +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + """ + Rebuild all database models which leverage the MPTT structure. + """ + + def handle(self, *args, **kwargs): + + # Part model + try: + print("Rebuilding Part objects") + + from part.models import Part + Part.objects.rebuild() + except: + print("Error rebuilding Part objects") + + # Part category + try: + print("Rebuilding PartCategory objects") + + from part.models import PartCategory + PartCategory.objects.rebuild() + except: + print("Error rebuilding PartCategory objects") + + # StockItem model + try: + print("Rebuilding StockItem objects") + + from stock.models import StockItem + StockItem.objects.rebuild() + except: + print("Error rebuilding StockItem objects") + + # StockLocation model + try: + print("Rebuilding StockLocation objects") + + from stock.models import StockLocation + StockLocation.objects.rebuild() + except: + print("Error rebuilding StockLocation objects") + + # Build model + try: + print("Rebuilding Build objects") + + from build.models import Build + Build.objects.rebuild() + except: + print("Error rebuilding Build objects") diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 160642281a..1cb973fe05 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -165,6 +165,19 @@ class BuildItemList(generics.ListCreateAPIView): serializer_class = BuildItemSerializer + def get_serializer(self, *args, **kwargs): + + try: + params = self.request.query_params + + kwargs['part_detail'] = str2bool(params.get('part_detail', False)) + kwargs['build_detail'] = str2bool(params.get('build_detail', False)) + kwargs['location_detail'] = str2bool(params.get('location_detail', False)) + except AttributeError: + pass + + return self.serializer_class(*args, **kwargs) + def get_queryset(self): """ Override the queryset method, to allow filtering by stock_item.part diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 629422f6e5..d8573cfa70 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -13,7 +13,8 @@ from rest_framework import serializers from InvenTree.serializers import InvenTreeModelSerializer from stock.serializers import StockItemSerializerBrief -from part.serializers import PartBriefSerializer +from stock.serializers import LocationSerializer +from part.serializers import PartSerializer, PartBriefSerializer from .models import Build, BuildItem @@ -99,22 +100,45 @@ class BuildItemSerializer(InvenTreeModelSerializer): bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True) part = serializers.IntegerField(source='stock_item.part.pk', read_only=True) - part_name = serializers.CharField(source='stock_item.part.full_name', read_only=True) - part_thumb = serializers.CharField(source='getStockItemThumbnail', read_only=True) + location = serializers.IntegerField(source='stock_item.location.pk', read_only=True) + + # Extra (optional) detail fields + part_detail = PartSerializer(source='stock_item.part', many=False, read_only=True) + build_detail = BuildSerializer(source='build', many=False, read_only=True) stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True) + location_detail = LocationSerializer(source='stock_item.location', read_only=True) quantity = serializers.FloatField() + def __init__(self, *args, **kwargs): + + build_detail = kwargs.pop('build_detail', False) + part_detail = kwargs.pop('part_detail', False) + location_detail = kwargs.pop('location_detail', False) + + super().__init__(*args, **kwargs) + + if not build_detail: + self.fields.pop('build_detail') + + if not part_detail: + self.fields.pop('part_detail') + + if not location_detail: + self.fields.pop('location_detail') + class Meta: model = BuildItem fields = [ 'pk', 'bom_part', 'build', + 'build_detail', 'install_into', + 'location', + 'location_detail', 'part', - 'part_name', - 'part_thumb', + 'part_detail', 'stock_item', 'stock_item_detail', 'quantity' diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index ff8b6d667b..83aef7531b 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -103,17 +103,11 @@ class ManufacturerPartList(generics.ListCreateAPIView): # Do we wish to include extra detail? try: - kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None)) - except AttributeError: - pass + params = self.request.query_params - try: - kwargs['manufacturer_detail'] = str2bool(self.request.query_params.get('manufacturer_detail', None)) - except AttributeError: - pass - - try: - kwargs['pretty'] = str2bool(self.request.query_params.get('pretty', None)) + kwargs['part_detail'] = str2bool(params.get('part_detail', None)) + kwargs['manufacturer_detail'] = str2bool(params.get('manufacturer_detail', None)) + kwargs['pretty'] = str2bool(params.get('pretty', None)) except AttributeError: pass @@ -252,22 +246,11 @@ class SupplierPartList(generics.ListCreateAPIView): # Do we wish to include extra detail? try: - kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None)) - except AttributeError: - pass - - try: - kwargs['supplier_detail'] = str2bool(self.request.query_params.get('supplier_detail', None)) - except AttributeError: - pass - - try: - kwargs['manufacturer_detail'] = str2bool(self.request.query_params.get('manufacturer_detail', None)) - except AttributeError: - pass - - try: - kwargs['pretty'] = str2bool(self.request.query_params.get('pretty', None)) + params = self.request.query_params + kwargs['part_detail'] = str2bool(params.get('part_detail', None)) + kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', None)) + kwargs['manufacturer_detail'] = str2bool(self.params.get('manufacturer_detail', None)) + kwargs['pretty'] = str2bool(params.get('pretty', None)) except AttributeError: pass diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 7eda59eacb..6661bd568b 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -22,9 +22,10 @@ from .models import PurchaseOrder, PurchaseOrderLineItem from .models import PurchaseOrderAttachment from .serializers import POSerializer, POLineItemSerializer, POAttachmentSerializer -from .models import SalesOrder, SalesOrderLineItem +from .models import SalesOrder, SalesOrderLineItem, SalesOrderAllocation from .models import SalesOrderAttachment from .serializers import SalesOrderSerializer, SOLineItemSerializer, SOAttachmentSerializer +from .serializers import SalesOrderAllocationSerializer class POList(generics.ListCreateAPIView): @@ -422,17 +423,11 @@ class SOLineItemList(generics.ListCreateAPIView): def get_serializer(self, *args, **kwargs): try: - kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False)) - except AttributeError: - pass + params = self.request.query_params - try: - kwargs['order_detail'] = str2bool(self.request.query_params.get('order_detail', False)) - except AttributeError: - pass - - try: - kwargs['allocations'] = str2bool(self.request.query_params.get('allocations', False)) + kwargs['part_detail'] = str2bool(params.get('part_detail', False)) + kwargs['order_detail'] = str2bool(params.get('order_detail', False)) + kwargs['allocations'] = str2bool(params.get('allocations', False)) except AttributeError: pass @@ -486,6 +481,70 @@ class SOLineItemDetail(generics.RetrieveUpdateAPIView): serializer_class = SOLineItemSerializer +class SOAllocationList(generics.ListCreateAPIView): + """ + API endpoint for listing SalesOrderAllocation objects + """ + + queryset = SalesOrderAllocation.objects.all() + serializer_class = SalesOrderAllocationSerializer + + def get_serializer(self, *args, **kwargs): + + try: + params = self.request.query_params + + kwargs['part_detail'] = str2bool(params.get('part_detail', False)) + kwargs['item_detail'] = str2bool(params.get('item_detail', False)) + kwargs['order_detail'] = str2bool(params.get('order_detail', False)) + kwargs['location_detail'] = str2bool(params.get('location_detail', False)) + except AttributeError: + pass + + return self.serializer_class(*args, **kwargs) + + def filter_queryset(self, queryset): + + queryset = super().filter_queryset(queryset) + + # Filter by order + params = self.request.query_params + + # Filter by "part" reference + part = params.get('part', None) + + if part is not None: + queryset = queryset.filter(item__part=part) + + # Filter by "order" reference + order = params.get('order', None) + + if order is not None: + queryset = queryset.filter(line__order=order) + + # Filter by "outstanding" order status + outstanding = params.get('outstanding', None) + + if outstanding is not None: + outstanding = str2bool(outstanding) + + if outstanding: + queryset = queryset.filter(line__order__status__in=SalesOrderStatus.OPEN) + else: + queryset = queryset.exclude(line__order__status__in=SalesOrderStatus.OPEN) + + return queryset + + filter_backends = [ + DjangoFilterBackend, + ] + + # Default filterable fields + filter_fields = [ + 'item', + ] + + class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin): """ API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload) @@ -494,10 +553,6 @@ class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin): queryset = PurchaseOrderAttachment.objects.all() serializer_class = POAttachmentSerializer - filter_fields = [ - 'order', - ] - order_api_urls = [ # API endpoints for purchase orders @@ -512,14 +567,26 @@ order_api_urls = [ url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'), # API endpoints for sales ordesr - url(r'^so/(?P\d+)/$', SODetail.as_view(), name='api-so-detail'), - url(r'so/attachment/', include([ - url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'), + url(r'^so/', include([ + url(r'^(?P\d+)/$', SODetail.as_view(), name='api-so-detail'), + url(r'attachment/', include([ + url(r'^.*$', SOAttachmentList.as_view(), name='api-so-attachment-list'), + ])), + + # List all sales orders + url(r'^.*$', SOList.as_view(), name='api-so-list'), ])), - url(r'^so/.*$', SOList.as_view(), name='api-so-list'), - # API endpoints for sales order line items - url(r'^so-line/(?P\d+)/$', SOLineItemDetail.as_view(), name='api-so-line-detail'), - url(r'^so-line/$', SOLineItemList.as_view(), name='api-so-line-list'), + url(r'^so-line/', include([ + url(r'^(?P\d+)/$', SOLineItemDetail.as_view(), name='api-so-line-detail'), + url(r'^$', SOLineItemList.as_view(), name='api-so-line-list'), + ])), + + # API endpoints for sales order allocations + url(r'^so-allocation', include([ + + # List all sales order allocations + url(r'^.*$', SOAllocationList.as_view(), name='api-so-allocation-list'), + ])), ] diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index a50c72e13e..9efbf947bb 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -18,6 +18,7 @@ from InvenTree.serializers import InvenTreeAttachmentSerializerField from company.serializers import CompanyBriefSerializer, SupplierPartSerializer from part.serializers import PartBriefSerializer from stock.serializers import LocationBriefSerializer +from stock.serializers import StockItemSerializer, LocationSerializer from .models import PurchaseOrder, PurchaseOrderLineItem from .models import PurchaseOrderAttachment, SalesOrderAttachment @@ -42,7 +43,7 @@ class POSerializer(InvenTreeModelSerializer): """ Add extra information to the queryset - - Number of liens in the PurchaseOrder + - Number of lines in the PurchaseOrder - Overdue status of the PurchaseOrder """ @@ -236,11 +237,38 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): This includes some fields from the related model objects. """ - location_path = serializers.CharField(source='get_location_path') - location_id = serializers.IntegerField(source='get_location') - serial = serializers.CharField(source='get_serial') - po = serializers.CharField(source='get_po') - quantity = serializers.FloatField() + part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True) + order = serializers.PrimaryKeyRelatedField(source='line.order', many=False, read_only=True) + serial = serializers.CharField(source='get_serial', read_only=True) + quantity = serializers.FloatField(read_only=True) + location = serializers.PrimaryKeyRelatedField(source='item.location', many=False, read_only=True) + + # Extra detail fields + order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True) + part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True) + item_detail = StockItemSerializer(source='item', many=False, read_only=True) + location_detail = LocationSerializer(source='item.location', many=False, read_only=True) + + def __init__(self, *args, **kwargs): + + order_detail = kwargs.pop('order_detail', False) + part_detail = kwargs.pop('part_detail', False) + item_detail = kwargs.pop('item_detail', False) + location_detail = kwargs.pop('location_detail', False) + + super().__init__(*args, **kwargs) + + if not order_detail: + self.fields.pop('order_detail') + + if not part_detail: + self.fields.pop('part_detail') + + if not item_detail: + self.fields.pop('item_detail') + + if not location_detail: + self.fields.pop('location_detail') class Meta: model = SalesOrderAllocation @@ -250,10 +278,14 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): 'line', 'serial', 'quantity', - 'location_id', - 'location_path', - 'po', + 'location', + 'location_detail', 'item', + 'item_detail', + 'order', + 'order_detail', + 'part', + 'part_detail', ] diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 3bbd458da5..b760409fe4 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -81,10 +81,10 @@ function showAllocationSubTable(index, row, element) { }, }, { - field: 'location_id', + field: 'location', title: 'Location', formatter: function(value, row, index, field) { - return renderLink(row.location_path, `/stock/location/${row.location_id}/`); + return renderLink(row.location_path, `/stock/location/${row.location}/`); }, }, { diff --git a/InvenTree/part/templates/part/allocation.html b/InvenTree/part/templates/part/allocation.html index 803df3906d..e78456ea3a 100644 --- a/InvenTree/part/templates/part/allocation.html +++ b/InvenTree/part/templates/part/allocation.html @@ -8,52 +8,43 @@ {% endblock %} {% block heading %} -{% trans "Part Stock Allocations" %} +{% trans "Build Order Allocations" %} {% endblock %} {% block details %} - - - - - - -{% for allocation in part.build_order_allocations %} - - - - - -{% endfor %} -{% for allocation in part.sales_order_allocations %} - - - - - -{% endfor %} -
{% trans "Order" %}{% trans "Stock Item" %}{% trans "Quantity" %}
{% trans "Build Order" %}: {{ allocation.build }}{% trans "Stock Item" %}: {{ allocation.stock_item }}{% decimal allocation.quantity %}
{% trans "Sales Order" %}: {{ allocation.line.order }}{% trans "Stock Item" %}: {{ allocation.item }}{% decimal allocation.quantity %}
+ +
{% endblock %} +{% block pre_content_panel %} + +
+
+

{% trans "Sales Order Allocations" %}

+
+ +
+
+
+
+ +{% endblock %} + + {% block js_ready %} {{ block.super }} - $("#build-table").inventreeTable({ - columns: [ - { - title: '{% trans "Order" %}', - sortable: true, - }, - { - title: '{% trans "Stock Item" %}', - sortable: true, - }, - { - title: '{% trans "Quantity" %}', - sortable: true, - } - ] + loadSalesOrderAllocationTable("#sales-order-table", { + params: { + part: {{ part.id }}, + } + }); + + loadBuildOrderAllocationTable("#build-order-table", { + params: { + part: {{ part.id }}, + } }); {% endblock %} diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 2e1cb2e71f..7e1d33bdea 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -195,8 +195,13 @@ +{% block pre_content_panel %} + +{% endblock %} +
+

{% block heading %} @@ -210,7 +215,11 @@ {% endblock %}

+
+{% block post_content_panel %} + +{% endblock %} {% endblock %} diff --git a/InvenTree/templates/js/build.js b/InvenTree/templates/js/build.js index 9523d24d39..e8af981817 100644 --- a/InvenTree/templates/js/build.js +++ b/InvenTree/templates/js/build.js @@ -155,6 +155,88 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) { } +function loadBuildOrderAllocationTable(table, options={}) { + /** + * Load a table showing all the BuildOrder allocations for a given part + */ + + options.params['part_detail'] = true; + options.params['build_detail'] = true; + options.params['location_detail'] = true; + + var filters = loadTableFilters("buildorderallocation"); + + for (var key in options.params) { + filters[key] = options.params[key]; + } + + setupFilterList("buildorderallocation", $(table)); + + $(table).inventreeTable({ + url: '{% url "api-build-item-list" %}', + queryParams: filters, + name: 'buildorderallocation', + groupBy: false, + search: false, + paginationVAlign: 'bottom', + original: options.params, + formatNoMatches: function() { + return '{% trans "No build order allocations found" %}' + }, + columns: [ + { + field: 'pk', + visible: false, + switchable: false, + }, + { + field: 'build', + switchable: false, + title: '{% trans "Build Order" %}', + formatter: function(value, row) { + var prefix = "{% settings_value 'BUILDORDER_REFERENCE_PREFIX' %}"; + + var ref = `${prefix}${row.build_detail.reference}`; + + return renderLink(ref, `/build/${row.build}/`); + } + }, + { + field: 'item', + title: '{% trans "Stock Item" %}', + formatter: function(value, row) { + // Render a link to the particular stock item + + var link = `/stock/item/${row.stock_item}/`; + var text = `{% trans "Stock Item" %} ${row.stock_item}`; + + return renderLink(text, link); + } + }, + { + field: 'location', + title: '{% trans "Location" %}', + formatter: function(value, row) { + + if (!value) { + return '{% trans "Location not specified" %}'; + } + + var link = `/stock/location/${value}`; + var text = row.location_detail.description; + + return renderLink(text, link); + } + }, + { + field: 'quantity', + title: '{% trans "Quantity" %}', + } + ] + }); +} + + function loadBuildOutputAllocationTable(buildInfo, output, options={}) { /* * Load the "allocation table" for a particular build output. @@ -347,6 +429,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { var params = { build: buildId, + part_detail: true, + location_detail: true, } if (output) { @@ -466,8 +550,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { title: '{% trans "Part" %}', formatter: function(value, row) { - var html = imageHoverIcon(row.part_thumb); - html += renderLink(row.part_name, `/part/${value}/`); + var html = imageHoverIcon(row.part_detail.thumbnail); + html += renderLink(row.part_detail.full_name, `/part/${value}/`); return html; } }, diff --git a/InvenTree/templates/js/order.js b/InvenTree/templates/js/order.js index fa60ebdf6d..649357b083 100644 --- a/InvenTree/templates/js/order.js +++ b/InvenTree/templates/js/order.js @@ -310,3 +310,88 @@ function loadSalesOrderTable(table, options) { ], }); } + + +function loadSalesOrderAllocationTable(table, options={}) { + /** + * Load a table with SalesOrderAllocation items + */ + + options.params = options.params || {}; + + options.params['location_detail'] = true; + options.params['part_detail'] = true; + options.params['item_detail'] = true; + options.params['order_detail'] = true; + + var filters = loadTableFilters("salesorderallocation"); + + for (var key in options.params) { + filters[key] = options.params[key]; + } + + setupFilterList("salesorderallocation", $(table)); + + $(table).inventreeTable({ + url: '{% url "api-so-allocation-list" %}', + queryParams: filters, + name: 'salesorderallocation', + groupBy: false, + search: false, + paginationVAlign: 'bottom', + original: options.params, + formatNoMatches: function() { return '{% trans "No sales order allocations found" %}'; }, + columns: [ + { + field: 'pk', + visible: false, + switchable: false, + }, + { + field: 'order', + switchable: false, + title: '{% trans "Order" %}', + switchable: false, + formatter: function(value, row) { + + var prefix = "{% settings_value 'SALESORDER_REFERENCE_PREFIX' %}"; + + var ref = `${prefix}${row.order_detail.reference}`; + + return renderLink(ref, `/order/sales-order/${row.order}/`); + } + }, + { + field: 'item', + title: '{% trans "Stock Item" %}', + formatter: function(value, row) { + // Render a link to the particular stock item + + var link = `/stock/item/${row.item}/`; + var text = `{% trans "Stock Item" %} ${row.item}`; + + return renderLink(text, link); + } + }, + { + field: 'location', + title: '{% trans "Location" %}', + formatter: function(value, row) { + + if (!value) { + return '{% trans "Location not specified" %}'; + } + + var link = `/stock/location/${value}`; + var text = row.location_detail.description; + + return renderLink(text, link); + } + }, + { + field: 'quantity', + title: '{% trans "Quantity" %}', + } + ] + }); +} \ No newline at end of file diff --git a/InvenTree/templates/js/tables.js b/InvenTree/templates/js/tables.js index 26d7ee4da9..645e54fcbb 100644 --- a/InvenTree/templates/js/tables.js +++ b/InvenTree/templates/js/tables.js @@ -135,7 +135,7 @@ $.fn.inventreeTable = function(options) { // Pagingation options (can be server-side or client-side as specified by the caller) options.pagination = true; - options.paginationVAlign = 'both'; + options.paginationVAlign = options.paginationVAlign || 'both'; options.pageSize = inventreeLoad(varName, 25); options.pageList = [25, 50, 100, 250, 'all']; options.totalField = 'count'; diff --git a/tasks.py b/tasks.py index 5aab30651a..4522629e25 100644 --- a/tasks.py +++ b/tasks.py @@ -129,6 +129,14 @@ def wait(c): manage(c, "wait_for_db") +@task +def rebuild(c): + """ + Rebuild database models with MPTT structures + """ + + manage(c, "rebuild_models") + @task def migrate(c): """ @@ -243,12 +251,15 @@ def content_excludes(): "contenttypes", "sessions.session", "auth.permission", + "authtoken.token", "error_report.error", "admin.logentry", "django_q.schedule", "django_q.task", "django_q.ormq", "users.owner", + "exchange.rate", + "exchange.exchangebackend", ] output = "" @@ -311,7 +322,7 @@ def export_records(c, filename='data.json'): print("Data export completed") -@task(help={'filename': 'Input filename'}) +@task(help={'filename': 'Input filename'}, post=[rebuild]) def import_records(c, filename='data.json'): """ Import database records from a file @@ -354,7 +365,7 @@ def import_records(c, filename='data.json'): print("Data import completed") -@task +@task(post=[rebuild]) def import_fixtures(c): """ Import fixture data into the database.