diff --git a/.github/workflows/docker_stable.yaml b/.github/workflows/docker_stable.yaml index 3d435e40da..1f3c7749c7 100644 --- a/.github/workflows/docker_stable.yaml +++ b/.github/workflows/docker_stable.yaml @@ -1,4 +1,5 @@ -# Build and push latest docker image on push to master branch +# Build and push docker image on push to 'stable' branch +# Docker build will be uploaded to dockerhub with the 'inventree:stable' tag name: Docker Build diff --git a/.github/workflows/docker_tag.yaml b/.github/workflows/docker_tag.yaml index b3b0c53d12..90b8f72505 100644 --- a/.github/workflows/docker_tag.yaml +++ b/.github/workflows/docker_tag.yaml @@ -1,4 +1,5 @@ -# Publish docker images to dockerhub +# Publish docker images to dockerhub on a tagged release +# Docker build will be uploaded to dockerhub with the 'invetree:' tag name: Docker Publish diff --git a/.github/workflows/docker_test.yaml b/.github/workflows/docker_test.yaml new file mode 100644 index 0000000000..0067f337e5 --- /dev/null +++ b/.github/workflows/docker_test.yaml @@ -0,0 +1,37 @@ +# Test that the InvenTree docker image compiles correctly + +# This CI action runs on pushes to either the master or stable branches + +# 1. Build the development docker image (as per the documentation) +# 2. Install requied python libs into the docker container +# 3. Launch the container +# 4. Check that the API endpoint is available + +name: Docker Test + +on: + push: + branches: + - 'master' + - 'stable' + +jobs: + + docker: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v2 + - name: Build Docker Image + run: | + cd docker + docker-compose -f docker-compose.dev.yml build + docker-compose -f docker-compose.dev.yml run inventree-dev-server invoke update + docker-compose -f docker-compose.dev.yml up -d + - name: Sleepy Time + run: sleep 60 + - name: Test API + run: | + pip install requests + python3 ci/check_api_endpoint.py diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 6456c5994f..31a887d736 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -63,6 +63,12 @@ class InvenTreeConfig(AppConfig): schedule_type=Schedule.DAILY, ) + # Delete old error messages + InvenTree.tasks.schedule_task( + 'InvenTree.tasks.delete_old_error_logs', + schedule_type=Schedule.DAILY, + ) + # Delete "old" stock items InvenTree.tasks.schedule_task( 'stock.tasks.delete_old_stock_items', diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 585c0b3825..71e518560b 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -242,6 +242,14 @@ border-color: var(--label-red); } +.label-form { + margin: 2px; + padding: 3px; + padding-left: 10px; + padding-right: 10px; + border-radius: 5px; +} + .label-red { background: var(--label-red); } diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 5fb6960601..3889f108af 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -156,7 +156,34 @@ def delete_successful_tasks(): started__lte=threshold ) - results.delete() + if results.count() > 0: + logger.info(f"Deleting {results.count()} successful task records") + results.delete() + + +def delete_old_error_logs(): + """ + Delete old error logs from the server + """ + + try: + from error_report.models import Error + + # Delete any error logs more than 30 days old + threshold = timezone.now() - timedelta(days=30) + + errors = Error.objects.filter( + when__lte=threshold, + ) + + if errors.count() > 0: + logger.info(f"Deleting {errors.count()} old error logs") + errors.delete() + + except AppRegistryNotReady: + # Apps not yet loaded + logger.info("Could not perform 'delete_old_error_logs' - App registry not ready") + return def check_for_updates(): @@ -215,7 +242,7 @@ def delete_expired_sessions(): # Delete any sessions that expired more than a day ago expired = Session.objects.filter(expire_date__lt=timezone.now() - timedelta(days=1)) - if True or expired.count() > 0: + if expired.count() > 0: logger.info(f"Deleting {expired.count()} expired sessions.") expired.delete() @@ -247,15 +274,15 @@ def update_exchange_rates(): pass except: # Some other error - print("Database not ready") + logger.warning("update_exchange_rates: Database not ready") return backend = InvenTreeExchange() - print(f"Updating exchange rates from {backend.url}") + logger.info(f"Updating exchange rates from {backend.url}") base = currency_code_default() - print(f"Using base currency '{base}'") + logger.info(f"Using base currency '{base}'") backend.update_rates(base_currency=base) diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 03ee877cb2..ac3c6178ff 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -10,12 +10,16 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" -INVENTREE_API_VERSION = 14 +INVENTREE_API_VERSION = 15 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about -v14 -> 2021-20-05 +v15 -> 2021-10-06 + - Adds detail endpoint for SalesOrderAllocation model + - Allows use of the API forms interface for adjusting SalesOrderAllocation objects + +v14 -> 2021-10-05 - Stock adjustment actions API is improved, using native DRF serializer support - However adjustment actions now only support 'pk' as a lookup field @@ -104,7 +108,7 @@ def inventreeDocsVersion(): Return the version string matching the latest documentation. Development -> "latest" - Release -> "major.minor" + Release -> "major.minor.sub" e.g. "0.5.2" """ @@ -113,7 +117,7 @@ def inventreeDocsVersion(): else: major, minor, patch = inventreeVersionTuple() - return f"{major}.{minor}" + return f"{major}.{minor}.{patch}" def isInvenTreeUpToDate(): diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 8e36624ad7..823fc8e512 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -648,14 +648,6 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, }, - # TODO: Remove this setting in future, new API forms make this not useful - 'PART_SHOW_QUANTITY_IN_FORMS': { - 'name': _('Show Quantity in Forms'), - 'description': _('Display available part quantity in some forms'), - 'default': True, - 'validator': bool, - }, - 'PART_SHOW_IMPORT': { 'name': _('Show Import in Views'), 'description': _('Display the import wizard in some part views'), @@ -1014,6 +1006,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): 'default': 10, 'validator': [int, MinValueValidator(1)] }, + + 'PART_SHOW_QUANTITY_IN_FORMS': { + 'name': _('Show Quantity in Forms'), + 'description': _('Display available part quantity in some forms'), + 'default': True, + 'validator': bool, + }, } class Meta: diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html index e4ca64b32e..b05a21304d 100644 --- a/InvenTree/company/templates/company/company_base.html +++ b/InvenTree/company/templates/company/company_base.html @@ -158,6 +158,12 @@ function reloadImage(data) { if (data.image) { $('#company-image').attr('src', data.image); + + // Reset the "modal image" view + $('#company-image').click(function() { + showModalImage(data.image); + }); + } else { location.reload(); } diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index 5d843fc624..54e91ed844 100644 --- a/InvenTree/order/admin.py +++ b/InvenTree/order/admin.py @@ -77,6 +77,14 @@ class POLineItemResource(ModelResource): class SOLineItemResource(ModelResource): """ Class for managing import / export of SOLineItem data """ + part_name = Field(attribute='part__name', readonly=True) + + IPN = Field(attribute='part__IPN', readonly=True) + + description = Field(attribute='part__description', readonly=True) + + fulfilled = Field(attribute='fulfilled_quantity', readonly=True) + class Meta: model = SalesOrderLineItem skip_unchanged = True diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 26e6ed3546..af30a3a5c5 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -631,6 +631,15 @@ class SOLineItemDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = SOLineItemSerializer +class SOAllocationDetail(generics.RetrieveUpdateDestroyAPIView): + """ + API endpoint for detali view of a SalesOrderAllocation object + """ + + queryset = SalesOrderAllocation.objects.all() + serializer_class = SalesOrderAllocationSerializer + + class SOAllocationList(generics.ListCreateAPIView): """ API endpoint for listing SalesOrderAllocation objects @@ -743,8 +752,10 @@ order_api_urls = [ ])), # API endpoints for purchase order line items - url(r'^po-line/(?P\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'), - url(r'^po-line/$', POLineItemList.as_view(), name='api-po-line-list'), + url(r'^po-line/', include([ + url(r'^(?P\d+)/$', POLineItemDetail.as_view(), name='api-po-line-detail'), + url(r'^.*$', POLineItemList.as_view(), name='api-po-line-list'), + ])), # API endpoints for sales ordesr url(r'^so/', include([ @@ -764,9 +775,8 @@ order_api_urls = [ ])), # API endpoints for sales order allocations - url(r'^so-allocation', include([ - - # List all sales order allocations + url(r'^so-allocation/', include([ + url(r'^(?P\d+)/$', SOAllocationDetail.as_view(), name='api-so-allocation-detail'), url(r'^.*$', SOAllocationList.as_view(), name='api-so-allocation-list'), ])), ] diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index 87e042f4f3..227109c46c 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -115,23 +115,6 @@ class AllocateSerialsToSalesOrderForm(forms.Form): ] -class CreateSalesOrderAllocationForm(HelperForm): - """ - Form for creating a SalesOrderAllocation item. - """ - - quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity')) - - class Meta: - model = SalesOrderAllocation - - fields = [ - 'line', - 'item', - 'quantity', - ] - - class EditSalesOrderAllocationForm(HelperForm): """ Form for editing a SalesOrderAllocation item diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 495ea2d333..4ac8925259 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -840,7 +840,13 @@ class SalesOrderLineItem(OrderLineItem): def get_api_url(): return reverse('api-so-line-list') - order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='lines', verbose_name=_('Order'), help_text=_('Sales Order')) + order = models.ForeignKey( + SalesOrder, + on_delete=models.CASCADE, + related_name='lines', + verbose_name=_('Order'), + help_text=_('Sales Order') + ) part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, verbose_name=_('Part'), help_text=_('Part'), limit_choices_to={'salable': True}) @@ -954,7 +960,11 @@ class SalesOrderAllocation(models.Model): if len(errors) > 0: raise ValidationError(errors) - line = models.ForeignKey(SalesOrderLineItem, on_delete=models.CASCADE, verbose_name=_('Line'), related_name='allocations') + line = models.ForeignKey( + SalesOrderLineItem, + on_delete=models.CASCADE, + verbose_name=_('Line'), + related_name='allocations') item = models.ForeignKey( 'stock.StockItem', diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 3886bfd3a5..40cd2def58 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -478,7 +478,7 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): 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) + quantity = serializers.FloatField(read_only=False) location = serializers.PrimaryKeyRelatedField(source='item.location', many=False, read_only=True) # Extra detail fields @@ -549,7 +549,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer): order_detail = SalesOrderSerializer(source='order', many=False, read_only=True) part_detail = PartBriefSerializer(source='part', many=False, read_only=True) - allocations = SalesOrderAllocationSerializer(many=True, read_only=True) + allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True) quantity = serializers.FloatField() diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 69e972da6c..8b98755900 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -39,6 +39,9 @@ src="{% static 'img/blank_image.png' %}" + {% if roles.purchase_order.change %} {% endif %} {% endif %} - {% endblock %} @@ -224,7 +224,7 @@ $("#cancel-order").click(function() { }); $("#export-order").click(function() { - location.href = "{% url 'po-export' order.id %}"; + exportOrder('{% url "po-export" order.id %}'); }); diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 6f8c422f7a..3fd34e42b9 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -50,6 +50,9 @@ src="{% static 'img/blank_image.png' %}" + {% if roles.sales_order.change %} {% endif %} {% endif %} + {% endblock %} @@ -196,4 +201,8 @@ $('#print-order-report').click(function() { printSalesOrderReports([{{ order.pk }}]); }); +$('#export-order').click(function() { + exportOrder('{% url "so-export" order.id %}'); +}); + {% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 30799e2296..bd853702c4 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -158,467 +158,38 @@ $("#so-lines-table").bootstrapTable("refresh"); } -$("#new-so-line").click(function() { + $("#new-so-line").click(function() { - constructForm('{% url "api-so-line-list" %}', { - fields: { - order: { - value: {{ order.pk }}, - hidden: true, - }, - part: {}, - quantity: {}, - reference: {}, - sale_price: {}, - sale_price_currency: {}, - notes: {}, - }, - method: 'POST', - title: '{% trans "Add Line Item" %}', - onSuccess: reloadTable, - }); -}); - -{% if order.status == SalesOrderStatus.PENDING %} -function showAllocationSubTable(index, row, element) { - // Construct a table showing stock items which have been allocated against this line item - - var html = `
`; - - element.html(html); - - var lineItem = row; - - var table = $(`#allocation-table-${row.pk}`); - - table.bootstrapTable({ - data: row.allocations, - showHeader: false, - columns: [ - { - width: '50%', - field: 'allocated', - title: '{% trans "Quantity" %}', - formatter: function(value, row, index, field) { - var text = ''; - - if (row.serial != null && row.quantity == 1) { - text = `{% trans "Serial Number" %}: ${row.serial}`; - } else { - text = `{% trans "Quantity" %}: ${row.quantity}`; - } - - return renderLink(text, `/stock/item/${row.item}/`); - }, - }, - { - field: 'location', - title: 'Location', - formatter: function(value, row, index, field) { - return renderLink(row.location_path, `/stock/location/${row.location}/`); - }, - }, - { - field: 'po' - }, - { - field: 'buttons', - title: '{% trans "Actions" %}', - formatter: function(value, row, index, field) { - - var html = "
"; - var pk = row.pk; - - {% if order.status == SalesOrderStatus.PENDING %} - html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); - {% endif %} - - html += "
"; - - return html; - }, - }, - ], - }); - - table.find(".button-allocation-edit").click(function() { - - var pk = $(this).attr('pk'); - - launchModalForm(`/order/sales-order/allocation/${pk}/edit/`, { - success: reloadTable, - }); - }); - - table.find(".button-allocation-delete").click(function() { - var pk = $(this).attr('pk'); - - launchModalForm(`/order/sales-order/allocation/${pk}/delete/`, { - success: reloadTable, - }); - }); -} -{% endif %} - -function showFulfilledSubTable(index, row, element) { - // Construct a table showing stock items which have been fulfilled against this line item - - var id = `fulfilled-table-${row.pk}`; - var html = `
`; - - element.html(html); - - var lineItem = row; - - $(`#${id}`).bootstrapTable({ - url: "{% url 'api-stock-list' %}", - queryParams: { - part: row.part, - sales_order: {{ order.id }}, - }, - showHeader: false, - columns: [ - { - field: 'pk', - visible: false, - }, - { - field: 'stock', - formatter: function(value, row) { - var text = ''; - if (row.serial && row.quantity == 1) { - text = `{% trans "Serial Number" %}: ${row.serial}`; - } else { - text = `{% trans "Quantity" %}: ${row.quantity}`; - } - - return renderLink(text, `/stock/item/${row.pk}/`); - }, - }, - { - field: 'po' - }, - ], - }); -} - -$("#so-lines-table").inventreeTable({ - formatNoMatches: function() { return "{% trans 'No matching line items' %}"; }, - queryParams: { - order: {{ order.id }}, - part_detail: true, - allocations: true, - }, - sidePagination: 'server', - uniqueId: 'pk', - url: "{% url 'api-so-line-list' %}", - onPostBody: setupCallbacks, - {% if order.status == SalesOrderStatus.PENDING or order.status == SalesOrderStatus.SHIPPED %} - detailViewByClick: true, - detailView: true, - detailFilter: function(index, row) { - {% if order.status == SalesOrderStatus.PENDING %} - return row.allocated > 0; - {% else %} - return row.fulfilled > 0; - {% endif %} - }, - {% if order.status == SalesOrderStatus.PENDING %} - detailFormatter: showAllocationSubTable, - {% else %} - detailFormatter: showFulfilledSubTable, - {% endif %} - {% endif %} - showFooter: true, - columns: [ - { - field: 'pk', - title: '{% trans "ID" %}', - visible: false, - switchable: false, - }, - { - sortable: true, - sortName: 'part__name', - field: 'part', - title: '{% trans "Part" %}', - formatter: function(value, row, index, field) { - if (row.part) { - return imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${value}/`); - } else { - return '-'; - } - }, - footerFormatter: function() { - return '{% trans "Total" %}' - }, - }, - { - sortable: true, - field: 'reference', - title: '{% trans "Reference" %}' - }, - { - sortable: true, - field: 'quantity', - title: '{% trans "Quantity" %}', - footerFormatter: function(data) { - return data.map(function (row) { - return +row['quantity'] - }).reduce(function (sum, i) { - return sum + i - }, 0) - }, - }, - { - sortable: true, - field: 'sale_price', - title: '{% trans "Unit Price" %}', - formatter: function(value, row) { - return row.sale_price_string || row.sale_price; - } - }, - { - sortable: true, - title: '{% trans "Total price" %}', - formatter: function(value, row) { - var total = row.sale_price * row.quantity; - var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: row.sale_price_currency}); - return formatter.format(total) - }, - footerFormatter: function(data) { - var total = data.map(function (row) { - return +row['sale_price']*row['quantity'] - }).reduce(function (sum, i) { - return sum + i - }, 0) - var currency = (data.slice(-1)[0] && data.slice(-1)[0].sale_price_currency) || 'USD'; - var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: currency}); - return formatter.format(total) - } - }, - - { - field: 'allocated', - {% if order.status == SalesOrderStatus.PENDING %} - title: '{% trans "Allocated" %}', - {% else %} - title: '{% trans "Fulfilled" %}', - {% endif %} - formatter: function(value, row, index, field) { - {% if order.status == SalesOrderStatus.PENDING %} - var quantity = row.allocated; - {% else %} - var quantity = row.fulfilled; - {% endif %} - return makeProgressBar(quantity, row.quantity, { - id: `order-line-progress-${row.pk}`, - }); - }, - sorter: function(valA, valB, rowA, rowB) { - {% if order.status == SalesOrderStatus.PENDING %} - var A = rowA.allocated; - var B = rowB.allocated; - {% else %} - var A = rowA.fulfilled; - var B = rowB.fulfilled; - {% endif %} - - if (A == 0 && B == 0) { - return (rowA.quantity > rowB.quantity) ? 1 : -1; - } - - var progressA = parseFloat(A) / rowA.quantity; - var progressB = parseFloat(B) / rowB.quantity; - - return (progressA < progressB) ? 1 : -1; - } - }, - { - field: 'notes', - title: '{% trans "Notes" %}', - }, - { - field: 'po', - title: '{% trans "PO" %}', - formatter: function(value, row, index, field) { - var po_name = ""; - if (row.allocated) { - row.allocations.forEach(function(allocation) { - if (allocation.po != po_name) { - if (po_name) { - po_name = "-"; - } else { - po_name = allocation.po - } - } - }) - } - return `
` + po_name + `
`; - } - }, - {% if order.status == SalesOrderStatus.PENDING %} - { - field: 'buttons', - formatter: function(value, row, index, field) { - - var html = `
`; - - var pk = row.pk; - - if (row.part) { - var part = row.part_detail; - - if (part.trackable) { - html += makeIconButton('fa-hashtag icon-green', 'button-add-by-sn', pk, '{% trans "Allocate serial numbers" %}'); - } - - html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', pk, '{% trans "Allocate stock" %}'); - - if (part.purchaseable) { - html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}'); - } - - if (part.assembly) { - html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build stock" %}'); - } - - html += makeIconButton('fa-dollar-sign icon-green', 'button-price', pk, '{% trans "Calculate price" %}'); - } - - html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line item " %}'); - - html += `
`; - - return html; - } - }, - {% endif %} - ], -}); - -function setupCallbacks() { - - var table = $("#so-lines-table"); - - // Set up callbacks for the row buttons - table.find(".button-edit").click(function() { - - var pk = $(this).attr('pk'); - - constructForm(`/api/order/so-line/${pk}/`, { + constructForm('{% url "api-so-line-list" %}', { fields: { + order: { + value: {{ order.pk }}, + hidden: true, + }, + part: {}, quantity: {}, reference: {}, sale_price: {}, sale_price_currency: {}, notes: {}, }, - title: '{% trans "Edit Line Item" %}', + method: 'POST', + title: '{% trans "Add Line Item" %}', onSuccess: reloadTable, }); }); - table.find(".button-delete").click(function() { - var pk = $(this).attr('pk'); - - constructForm(`/api/order/so-line/${pk}/`, { - method: 'DELETE', - title: '{% trans "Delete Line Item" %}', - onSuccess: reloadTable, - }); - }); - - table.find(".button-add-by-sn").click(function() { - var pk = $(this).attr('pk'); - - inventreeGet(`/api/order/so-line/${pk}/`, {}, - { - success: function(response) { - launchModalForm('{% url "so-assign-serials" %}', { - success: reloadTable, - data: { - line: pk, - part: response.part, - } - }); - } - } - ); - }); - - table.find(".button-add").click(function() { - var pk = $(this).attr('pk'); - - launchModalForm(`/order/sales-order/allocation/new/`, { - success: reloadTable, - data: { - line: pk, - }, - }); - }); - - table.find(".button-build").click(function() { - - var pk = $(this).attr('pk'); - - // Extract the row data from the table! - var idx = $(this).closest('tr').attr('data-index'); - - var row = table.bootstrapTable('getData')[idx]; - - var quantity = 1; - - if (row.allocated < row.quantity) { - quantity = row.quantity - row.allocated; + loadSalesOrderLineItemTable( + '#so-lines-table', + { + order: {{ order.pk }}, + status: {{ order.status }}, } - - launchModalForm(`/build/new/`, { - follow: true, - data: { - part: pk, - sales_order: {{ order.id }}, - quantity: quantity, - }, - }); - }); - - table.find(".button-buy").click(function() { - var pk = $(this).attr('pk'); - - launchModalForm("{% url 'order-parts' %}", { - data: { - parts: [pk], - }, - }); - }); - - $(".button-price").click(function() { - var pk = $(this).attr('pk'); - var idx = $(this).closest('tr').attr('data-index'); - var row = table.bootstrapTable('getData')[idx]; - - launchModalForm( - "{% url 'line-pricing' %}", - { - submit_text: '{% trans "Calculate price" %}', - data: { - line_item: pk, - quantity: row.quantity, - }, - buttons: [{name: 'update_price', - title: '{% trans "Update Unit Price" %}'},], - success: reloadTable, - } - ); - }); + ); attachNavCallbacks({ name: 'sales-order', default: 'order-items' }); -} {% endblock %} \ No newline at end of file diff --git a/InvenTree/order/templates/order/so_allocation_delete.html b/InvenTree/order/templates/order/so_allocation_delete.html deleted file mode 100644 index 34cf20083b..0000000000 --- a/InvenTree/order/templates/order/so_allocation_delete.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "modal_delete_form.html" %} -{% load i18n %} -{% load inventree_extras %} - -{% block pre_form_content %} -
- {% trans "This action will unallocate the following stock from the Sales Order" %}: -
- - {% decimal allocation.get_allocated %} x {{ allocation.line.part.full_name }} - {% if allocation.item.location %} ({{ allocation.get_location }}){% endif %} - -
-{% endblock %} \ No newline at end of file diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index 5ea9a56867..afc689cc23 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -36,6 +36,7 @@ purchase_order_urls = [ sales_order_detail_urls = [ url(r'^cancel/', views.SalesOrderCancel.as_view(), name='so-cancel'), url(r'^ship/', views.SalesOrderShip.as_view(), name='so-ship'), + url(r'^export/', views.SalesOrderExport.as_view(), name='so-export'), url(r'^.*$', views.SalesOrderDetail.as_view(), name='so-detail'), ] @@ -43,12 +44,7 @@ sales_order_detail_urls = [ sales_order_urls = [ # URLs for sales order allocations url(r'^allocation/', include([ - url(r'^new/', views.SalesOrderAllocationCreate.as_view(), name='so-allocation-create'), url(r'^assign-serials/', views.SalesOrderAssignSerials.as_view(), name='so-assign-serials'), - url(r'(?P\d+)/', include([ - url(r'^edit/', views.SalesOrderAllocationEdit.as_view(), name='so-allocation-edit'), - url(r'^delete/', views.SalesOrderAllocationDelete.as_view(), name='so-allocation-delete'), - ])), ])), # Display detail view for a single SalesOrder diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 8a5e709926..08741faa2e 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -23,13 +23,12 @@ from decimal import Decimal, InvalidOperation from .models import PurchaseOrder, PurchaseOrderLineItem from .models import SalesOrder, SalesOrderLineItem from .models import SalesOrderAllocation -from .admin import POLineItemResource +from .admin import POLineItemResource, SOLineItemResource from build.models import Build from company.models import Company, SupplierPart # ManufacturerPart from stock.models import StockItem from part.models import Part -from common.models import InvenTreeSetting from common.forms import UploadFileForm, MatchFieldForm from common.views import FileManagementFormView from common.files import FileManager @@ -37,7 +36,7 @@ from common.files import FileManager from . import forms as order_forms from part.views import PartPricing -from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView +from InvenTree.views import AjaxView, AjaxUpdateView from InvenTree.helpers import DownloadFile, str2bool from InvenTree.helpers import extract_serial_numbers from InvenTree.views import InvenTreeRoleMixin @@ -437,6 +436,33 @@ class PurchaseOrderUpload(FileManagementFormView): return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']})) +class SalesOrderExport(AjaxView): + """ + Export a sales order + + - File format can optionally be passed as a query parameter e.g. ?format=CSV + - Default file format is CSV + """ + + model = SalesOrder + + role_required = 'sales_order.view' + + def get(self, request, *args, **kwargs): + + order = get_object_or_404(SalesOrder, pk=self.kwargs.get('pk', None)) + + export_format = request.GET.get('format', 'csv') + + filename = f"{str(order)} - {order.customer.name}.{export_format}" + + dataset = SOLineItemResource().export(queryset=order.lines.all()) + + filedata = dataset.export(format=export_format) + + return DownloadFile(filedata, filename) + + class PurchaseOrderExport(AjaxView): """ File download for a purchase order @@ -451,7 +477,7 @@ class PurchaseOrderExport(AjaxView): def get(self, request, *args, **kwargs): - order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk']) + order = get_object_or_404(PurchaseOrder, pk=self.kwargs.get('pk', None)) export_format = request.GET.get('format', 'csv') @@ -976,105 +1002,6 @@ class SalesOrderAssignSerials(AjaxView, FormMixin): ) -class SalesOrderAllocationCreate(AjaxCreateView): - """ View for creating a new SalesOrderAllocation """ - - model = SalesOrderAllocation - form_class = order_forms.CreateSalesOrderAllocationForm - ajax_form_title = _('Allocate Stock to Order') - - def get_initial(self): - initials = super().get_initial().copy() - - line_id = self.request.GET.get('line', None) - - if line_id is not None: - line = SalesOrderLineItem.objects.get(pk=line_id) - - initials['line'] = line - - # Search for matching stock items, pre-fill if there is only one - items = StockItem.objects.filter(part=line.part) - - quantity = line.quantity - line.allocated_quantity() - - if quantity < 0: - quantity = 0 - - if items.count() == 1: - item = items.first() - initials['item'] = item - - # Reduce the quantity IF there is not enough stock - qmax = item.quantity - item.allocation_count() - - if qmax < quantity: - quantity = qmax - - initials['quantity'] = quantity - - return initials - - def get_form(self): - - form = super().get_form() - - line_id = form['line'].value() - - # If a line item has been specified, reduce the queryset for the stockitem accordingly - try: - line = SalesOrderLineItem.objects.get(pk=line_id) - - # Construct a queryset for allowable stock items - queryset = StockItem.objects.filter(StockItem.IN_STOCK_FILTER) - - # Ensure the part reference matches - queryset = queryset.filter(part=line.part) - - # Exclude StockItem which are already allocated to this order - allocated = [allocation.item.pk for allocation in line.allocations.all()] - - queryset = queryset.exclude(pk__in=allocated) - - # Exclude stock items which have expired - if not InvenTreeSetting.get_setting('STOCK_ALLOW_EXPIRED_SALE'): - queryset = queryset.exclude(StockItem.EXPIRED_FILTER) - - form.fields['item'].queryset = queryset - - # Hide the 'line' field - form.fields['line'].widget = HiddenInput() - - except (ValueError, SalesOrderLineItem.DoesNotExist): - pass - - return form - - -class SalesOrderAllocationEdit(AjaxUpdateView): - - model = SalesOrderAllocation - form_class = order_forms.EditSalesOrderAllocationForm - ajax_form_title = _('Edit Allocation Quantity') - - def get_form(self): - form = super().get_form() - - # Prevent the user from editing particular fields - form.fields.pop('item') - form.fields.pop('line') - - return form - - -class SalesOrderAllocationDelete(AjaxDeleteView): - - model = SalesOrderAllocation - ajax_form_title = _("Remove allocation") - context_object_name = 'allocation' - ajax_template_name = "order/so_allocation_delete.html" - - class LineItemPricing(PartPricing): """ View for inspecting part pricing information """ diff --git a/InvenTree/part/bom.py b/InvenTree/part/bom.py index 81a0a4eb00..f67e4ffe8f 100644 --- a/InvenTree/part/bom.py +++ b/InvenTree/part/bom.py @@ -189,12 +189,15 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa # Process manufacturer part for manufacturer_idx, manufacturer_part in enumerate(manufacturer_parts): - if manufacturer_part: + if manufacturer_part and manufacturer_part.manufacturer: manufacturer_name = manufacturer_part.manufacturer.name else: manufacturer_name = '' - manufacturer_mpn = manufacturer_part.MPN + if manufacturer_part: + manufacturer_mpn = manufacturer_part.MPN + else: + manufacturer_mpn = '' # Generate column names for this manufacturer k_man = manufacturer_headers[0] + "_" + str(manufacturer_idx) @@ -210,12 +213,15 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa # Process supplier parts for supplier_idx, supplier_part in enumerate(manufacturer_part.supplier_parts.all()): - if supplier_part.supplier: + if supplier_part.supplier and supplier_part.supplier: supplier_name = supplier_part.supplier.name else: supplier_name = '' - supplier_sku = supplier_part.SKU + if supplier_part: + supplier_sku = supplier_part.SKU + else: + supplier_sku = '' # Generate column names for this supplier k_sup = str(supplier_headers[0]) + "_" + str(manufacturer_idx) + "_" + str(supplier_idx) diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index a81f918013..c1542ac13b 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -328,6 +328,12 @@ // If image / thumbnail data present, live update if (data.image) { $('#part-image').attr('src', data.image); + + // Reset the "modal image" view + $('#part-thumb').click(function() { + showModalImage(data.image); + }); + } else { // Otherwise, reload the page location.reload(); diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index ad487c7a5a..d848f0e6b9 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -34,6 +34,7 @@ from company.models import Company, SupplierPart from company.serializers import CompanySerializer, SupplierPartSerializer from order.models import PurchaseOrder +from order.models import SalesOrder, SalesOrderAllocation from order.serializers import POSerializer import common.settings @@ -645,6 +646,31 @@ class StockList(generics.ListCreateAPIView): # Filter StockItem without build allocations or sales order allocations queryset = queryset.filter(Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True)) + # Exclude StockItems which are already allocated to a particular SalesOrder + exclude_so_allocation = params.get('exclude_so_allocation', None) + + if exclude_so_allocation is not None: + + try: + order = SalesOrder.objects.get(pk=exclude_so_allocation) + + # Grab all the active SalesOrderAllocations for this order + allocations = SalesOrderAllocation.objects.filter( + line__pk__in=[ + line.pk for line in order.lines.all() + ] + ) + + # Exclude any stock item which is already allocated to the sales order + queryset = queryset.exclude( + pk__in=[ + a.item.pk for a in allocations + ] + ) + + except (ValueError, SalesOrder.DoesNotExist): + pass + # Does the client wish to filter by the Part ID? part_id = params.get('part', None) diff --git a/InvenTree/templates/InvenTree/settings/navbar.html b/InvenTree/templates/InvenTree/settings/navbar.html index aa4db644a6..58673d6618 100644 --- a/InvenTree/templates/InvenTree/settings/navbar.html +++ b/InvenTree/templates/InvenTree/settings/navbar.html @@ -42,6 +42,12 @@ +
  • + + {% trans "Forms" %} + +
  • +