From 2378073c61ed578e4b34b8d336b4b398c2607178 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 25 May 2022 11:00:19 +1000 Subject: [PATCH 01/16] SalesOrderShipment improvements (#3058) * Adds two new fields to SalesOrderShipment model: - Invoice number (char) - Link (URL) * Bump API version * Update API / serializer / JS - Allow entry of new fields at time of shipping * PEP fixes * Actually fix the PEP issues * Unit testing: check some more fields --- InvenTree/InvenTree/api_version.py | 5 ++- .../migrations/0069_auto_20220524_0508.py | 23 ++++++++++++ InvenTree/order/models.py | 26 +++++++++++++ InvenTree/order/serializers.py | 13 ++++--- InvenTree/order/test_api.py | 4 ++ InvenTree/templates/js/translated/order.js | 37 +++++++++++++++++-- 6 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 InvenTree/order/migrations/0069_auto_20220524_0508.py diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index e44aedf10b..993c7e9980 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -4,11 +4,14 @@ InvenTree API version information # InvenTree API version -INVENTREE_API_VERSION = 50 +INVENTREE_API_VERSION = 51 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v51 -> 2022-05-24 : https://github.com/inventree/InvenTree/pull/3058 + - Adds new fields to the SalesOrderShipment model + v50 -> 2022-05-18 : https://github.com/inventree/InvenTree/pull/2912 - Implement Attachments for manufacturer parts diff --git a/InvenTree/order/migrations/0069_auto_20220524_0508.py b/InvenTree/order/migrations/0069_auto_20220524_0508.py new file mode 100644 index 0000000000..ff37522332 --- /dev/null +++ b/InvenTree/order/migrations/0069_auto_20220524_0508.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.13 on 2022-05-24 05:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0068_alter_salesorderallocation_unique_together'), + ] + + operations = [ + migrations.AddField( + model_name='salesordershipment', + name='invoice_number', + field=models.CharField(blank=True, help_text='Reference number for associated invoice', max_length=100, verbose_name='Invoice Number'), + ), + migrations.AddField( + model_name='salesordershipment', + name='link', + field=models.URLField(blank=True, help_text='Link to external page', verbose_name='Link'), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 550070e5df..0a5f05ba0f 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -1202,6 +1202,20 @@ class SalesOrderShipment(models.Model): help_text=_('Shipment tracking information'), ) + invoice_number = models.CharField( + max_length=100, + blank=True, + unique=False, + verbose_name=_('Invoice Number'), + help_text=_('Reference number for associated invoice'), + ) + + link = models.URLField( + blank=True, + verbose_name=_('Link'), + help_text=_('Link to external page') + ) + def is_complete(self): return self.shipment_date is not None @@ -1253,6 +1267,18 @@ class SalesOrderShipment(models.Model): if tracking_number is not None: self.tracking_number = tracking_number + # Was an invoice number provided? + invoice_number = kwargs.get('invoice_number', None) + + if invoice_number is not None: + self.invoice_number = invoice_number + + # Was a link provided? + link = kwargs.get('link', None) + + if link is not None: + self.link = link + self.save() trigger_event('salesordershipment.completed', id=self.pk) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 685697f99f..0743d95c33 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -886,6 +886,8 @@ class SalesOrderShipmentSerializer(InvenTreeModelSerializer): 'checked_by', 'reference', 'tracking_number', + 'invoice_number', + 'link', 'notes', ] @@ -899,8 +901,10 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer): model = order.models.SalesOrderShipment fields = [ - 'tracking_number', 'shipment_date', + 'tracking_number', + 'invoice_number', + 'link', ] def validate(self, data): @@ -928,15 +932,14 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer): request = self.context['request'] user = request.user - # Extract provided tracking number (optional) - tracking_number = data.get('tracking_number', shipment.tracking_number) - # Extract shipping date (defaults to today's date) shipment_date = data.get('shipment_date', datetime.now()) shipment.complete_shipment( user, - tracking_number=tracking_number, + tracking_number=data.get('tracking_number', shipment.tracking_number), + invoice_number=data.get('invoice_number', shipment.invoice_number), + link=data.get('link', shipment.link), shipment_date=shipment_date, ) diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index dd17a033c9..6be694bb11 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -1324,6 +1324,8 @@ class SalesOrderAllocateTest(OrderTest): response = self.post( url, { + 'invoice_number': 'INV01234', + 'link': 'http://test.com/link.html', 'tracking_number': 'TRK12345', 'shipment_date': '2020-12-05', }, @@ -1334,6 +1336,8 @@ class SalesOrderAllocateTest(OrderTest): self.assertTrue(self.shipment.is_complete()) self.assertEqual(self.shipment.tracking_number, 'TRK12345') + self.assertEqual(self.shipment.invoice_number, 'INV01234') + self.assertEqual(self.shipment.link, 'http://test.com/link.html') def test_sales_order_shipment_list(self): diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 5fa0d2f77a..03e967deee 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -55,6 +55,12 @@ function salesOrderShipmentFields(options={}) { tracking_number: { icon: 'fa-hashtag', }, + invoice_number: { + icon: 'fa-dollar-sign', + }, + link: { + icon: 'fa-link', + } }; // If order is specified, hide the order field @@ -129,11 +135,20 @@ function completeShipment(shipment_id, options={}) { method: 'POST', title: `{% trans "Complete Shipment" %} ${shipment.reference}`, fields: { - tracking_number: { - value: shipment.tracking_number, - }, shipment_date: { value: moment().format('YYYY-MM-DD'), + }, + tracking_number: { + value: shipment.tracking_number, + icon: 'fa-hashtag', + }, + invoice_number: { + value: shipment.invoice_number, + icon: 'fa-dollar-sign', + }, + link: { + value: shipment.link, + icon: 'fa-link', } }, preFormContent: html, @@ -2445,10 +2460,26 @@ function loadSalesOrderShipmentTable(table, options={}) { field: 'tracking_number', title: '{% trans "Tracking" %}', }, + { + field: 'invoice_number', + title: '{% trans "Invoice" %}', + }, + { + field: 'link', + title: '{% trans "Link" %}', + formatter: function(value) { + if (value) { + return renderLink(value, value); + } else { + return '-'; + } + } + }, { field: 'notes', title: '{% trans "Notes" %}', visible: false, + switchable: false, // TODO: Implement 'notes' field }, { From 0e9ea1be0ca49ba9b9774949bcabed3d00701e5e Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 25 May 2022 19:58:14 +1000 Subject: [PATCH 02/16] Update CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f1bdc6ad85..0c82d5f51d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Please read the contribution guidelines below, before submitting your first pull ## Setup -Please run `invoke setup_dev` in the root directory of your InvenTree code base to set up your development setup before starting to contribute. This will install and set up pre-commit to run some checks before each commit and help reduce the style errors. +Please run `invoke setup-dev` in the root directory of your InvenTree code base to set up your development setup before starting to contribute. This will install and set up pre-commit to run some checks before each commit and help reduce the style errors. ## Branches and Versioning From 2d1776a151721d65d0ae007049d358085b2fcfd5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 25 May 2022 23:35:53 +1000 Subject: [PATCH 03/16] Refactor 'DeleteManufacturerPart' form (#3067) * Refactor 'DeleteManufacturerPart' form - Remove duplicated forms - Update style to match other forms - Block on each deletion before progressing to the next one * PEP fix * Adds deleteSupplierParts function * Refactor all instances of supplier part deletion * Refactor tractor : use convenience function for bootstraptable.getSelections * Add deleter for manufacturerpartparameter. Refactor existing code into a single function * Refactor deletion for stock items * JS linting --- InvenTree/build/templates/build/detail.html | 6 +- InvenTree/build/templates/build/index.html | 3 +- .../company/templates/company/detail.html | 43 ++-- .../templates/company/manufacturer_part.html | 107 +++----- .../company/manufacturer_part_sidebar.html | 4 +- .../order/purchase_order_detail.html | 6 +- .../templates/order/purchase_orders.html | 2 +- .../order/templates/order/sales_orders.html | 2 +- InvenTree/part/templates/part/detail.html | 40 +-- InvenTree/stock/templates/stock/location.html | 2 +- InvenTree/templates/js/translated/api.js | 45 ++++ InvenTree/templates/js/translated/bom.js | 37 +-- InvenTree/templates/js/translated/build.js | 25 +- InvenTree/templates/js/translated/company.js | 238 +++++++++++++----- InvenTree/templates/js/translated/forms.js | 24 +- InvenTree/templates/js/translated/modals.js | 11 + InvenTree/templates/js/translated/part.js | 4 +- InvenTree/templates/js/translated/stock.js | 45 ++-- InvenTree/templates/js/translated/tables.js | 18 ++ 19 files changed, 371 insertions(+), 291 deletions(-) diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index dca3fe359d..3b612bcfed 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -527,11 +527,7 @@ $('#btn-unallocate').on('click', function() { $('#allocate-selected-items').click(function() { - var bom_items = $("#allocation-table-untracked").bootstrapTable("getSelections"); - - if (bom_items.length == 0) { - bom_items = $("#allocation-table-untracked").bootstrapTable('getData'); - } + var bom_items = getTableData('#allocation-table-untracked'); allocateStockToBuild( {{ build.pk }}, diff --git a/InvenTree/build/templates/build/index.html b/InvenTree/build/templates/build/index.html index 4dbf2aba7a..a70d5d0d95 100644 --- a/InvenTree/build/templates/build/index.html +++ b/InvenTree/build/templates/build/index.html @@ -185,8 +185,7 @@ loadBuildTable($("#build-table"), { {% if report_enabled %} $('#multi-build-print').click(function() { - var rows = $("#build-table").bootstrapTable('getSelections'); - + var rows = getTableData("#build-table"); var build_ids = []; rows.forEach(function(row) { diff --git a/InvenTree/company/templates/company/detail.html b/InvenTree/company/templates/company/detail.html index 9ae40f0833..a5e2e159fc 100644 --- a/InvenTree/company/templates/company/detail.html +++ b/InvenTree/company/templates/company/detail.html @@ -28,8 +28,8 @@
-
{% endblock %} -{% block js_load %} -{{ block.super }} - - -{% endblock %} - {% block js_ready %} {{ block.super }} -$('#build-order-calendar').hide(); -$('#view-list').hide(); - -$('#view-calendar').click(function() { - // Hide the list view, show the calendar view - $("#build-table").hide(); - $("#view-calendar").hide(); - $(".fixed-table-pagination").hide(); - $(".columns-right").hide(); - $(".search").hide(); - - $("#build-order-calendar").show(); - $("#view-list").show(); - - calendar.render(); -}); - -$("#view-list").click(function() { - // Hide the calendar view, show the list view - $("#build-order-calendar").hide(); - $("#view-list").hide(); - - $(".fixed-table-pagination").show(); - $(".columns-right").show(); - $(".search").show(); - $("#build-table").show(); - $("#view-calendar").show(); -}); - -$("#collapse-item-active").collapse().show(); - $("#new-build").click(function() { newBuildOrder(); }); loadBuildTable($("#build-table"), { - url: "{% url 'api-build-list' %}", + locale: '{{ request.LANGUAGE_CODE }}', }); {% if report_enabled %} diff --git a/InvenTree/order/templates/order/purchase_orders.html b/InvenTree/order/templates/order/purchase_orders.html index e66863cbb5..7f91f01600 100644 --- a/InvenTree/order/templates/order/purchase_orders.html +++ b/InvenTree/order/templates/order/purchase_orders.html @@ -31,12 +31,6 @@ {% endif %} - - {% include "filter_list.html" with id="purchaseorder" %}
@@ -54,122 +48,11 @@ {% block js_load %} {{ block.super }} - - {% endblock %} {% block js_ready %} {{ block.super }} -$('#purchase-order-calendar').hide(); -$('#view-list').hide(); - -$('#view-calendar').click(function() { - // Hide the list view, show the calendar view - $("#purchase-order-table").hide(); - $("#view-calendar").hide(); - $(".fixed-table-pagination").hide(); - $(".columns-right").hide(); - $(".search").hide(); - $('#filter-list-salesorder').hide(); - - $("#purchase-order-calendar").show(); - $("#view-list").show(); - - calendar.render(); -}); - -$("#view-list").click(function() { - // Hide the calendar view, show the list view - $("#purchase-order-calendar").hide(); - $("#view-list").hide(); - - $(".fixed-table-pagination").show(); - $(".columns-right").show(); - $(".search").show(); - $("#purchase-order-table").show(); - $('#filter-list-salesorder').show(); - $("#view-calendar").show(); -}); - {% if report_enabled %} $("#order-print").click(function() { var rows = getTableData('#purchase-order-table'); diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 48a62114dc..89346c8118 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -231,7 +231,7 @@ }); loadBuildTable($("#builds-table"), { - url: "{% url 'api-build-list' %}", + locale: '{{ request.LANGUAGE_CODE }}', params: { sales_order: {{ order.id }}, }, diff --git a/InvenTree/order/templates/order/sales_orders.html b/InvenTree/order/templates/order/sales_orders.html index 23580567b9..9b85df95b7 100644 --- a/InvenTree/order/templates/order/sales_orders.html +++ b/InvenTree/order/templates/order/sales_orders.html @@ -34,12 +34,6 @@ {% endif %} - - {% include "filter_list.html" with id="salesorder" %} @@ -53,123 +47,9 @@ {% endblock %} -{% block js_load %} -{{ block.super }} - - -{% endblock %} - {% block js_ready %} {{ block.super }} -$("#sales-order-calendar").hide(); -$("#view-list").hide(); - -$('#view-calendar').click(function() { - // Hide the list view, show the calendar view - $("#sales-order-table").hide(); - $("#view-calendar").hide(); - $(".fixed-table-pagination").hide(); - $(".columns-right").hide(); - $(".search").hide(); - $('#filter-list-salesorder').hide(); - - $("#sales-order-calendar").show(); - $("#view-list").show(); - - calendar.render(); -}); - -$("#view-list").click(function() { - // Hide the calendar view, show the list view - $("#sales-order-calendar").hide(); - $("#view-list").hide(); - - $(".fixed-table-pagination").show(); - $(".columns-right").show(); - $(".search").show(); - $("#sales-order-table").show(); - $('#filter-list-salesorder').show(); - $("#view-calendar").show(); -}); - loadSalesOrderTable("#sales-order-table", { url: "{% url 'api-so-list' %}", }); diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 4f67a9bbc9..0fbe7828dc 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -514,7 +514,7 @@ }); loadBuildTable($("#build-table"), { - url: "{% url 'api-build-list' %}", + locale: '{{ request.LANGUAGE_CODE }}', params: { part: {{ part.id }}, } diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html index 44bc70fc37..836da5f870 100644 --- a/InvenTree/templates/InvenTree/index.html +++ b/InvenTree/templates/InvenTree/index.html @@ -223,7 +223,7 @@ addHeaderTitle('{% trans "Build Orders" %}'); {% if setting_build_pending %} addHeaderAction('build-pending', '{% trans "Build Orders In Progress" %}', 'fa-cogs'); loadBuildTable("#table-build-pending", { - url: "{% url 'api-build-list' %}", + locale: '{{ request.LANGUAGE_CODE }}', params: { active: true, }, @@ -234,7 +234,7 @@ loadBuildTable("#table-build-pending", { {% if setting_build_overdue %} addHeaderAction('build-overdue', '{% trans "Overdue Build Orders" %}', 'fa-calendar-times'); loadBuildTable("#table-build-overdue", { - url: "{% url 'api-build-list' %}", + locale: '{{ request.LANGUAGE_CODE }}', params: { overdue: true, }, diff --git a/InvenTree/templates/InvenTree/search.html b/InvenTree/templates/InvenTree/search.html index 5ba618137e..eb22cc0889 100644 --- a/InvenTree/templates/InvenTree/search.html +++ b/InvenTree/templates/InvenTree/search.html @@ -139,6 +139,7 @@ addItem('build-order', '{% trans "Build Orders" %}', 'fa-tools'); loadBuildTable('#table-build-order', { + locale: '{{ request.LANGUAGE_CODE }}', params: { original_search: '{{ query }}', } diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 8c8152becd..7d7cb59240 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -2320,6 +2320,9 @@ function autoAllocateStockToBuild(build_id, bom_items=[], options={}) { */ function loadBuildTable(table, options) { + // Ensure the table starts in a known state + $(table).bootstrapTable('destroy'); + var params = options.params || {}; var filters = {}; @@ -2334,23 +2337,105 @@ function loadBuildTable(table, options) { filters[key] = params[key]; } - options.url = options.url || '{% url "api-build-list" %}'; - var filterTarget = options.filterTarget || null; setupFilterList('build', table, filterTarget, {download: true}); + // Which display mode to use for the build table? + var display_mode = inventreeLoad('build-table-display-mode', 'list'); + var tree_enable = display_mode == 'tree'; + + var loaded_calendar = false; + + // Function for rendering BuildOrder calendar display + function buildEvents(calendar) { + var start = startDate(calendar); + var end = endDate(calendar); + + clearEvents(calendar); + + // Extract current filters from table + var table_options = $(table).bootstrapTable('getOptions'); + var filters = table_options.query_params || {}; + + filters.min_date = start; + filters.max_date = end; + filters.part_detail = true; + + // Request build orders from the server within specified date range + inventreeGet( + '{% url "api-build-list" %}', + filters, + { + success: function(response) { + var prefix = global_settings.BUILDORDER_REFERENCE_PREFIX; + + for (var idx = 0; idx < response.length; idx++) { + + var order = response[idx]; + + var date = order.creation_date; + + if (order.completion_date) { + date = order.completion_date; + } else if (order.target_date) { + date = order.target_date; + } + + var title = `${prefix}${order.reference}`; + + var color = '#4c68f5'; + + if (order.completed) { + color = '#25c234'; + } else if (order.overdue) { + color = '#c22525'; + } + + var event = { + title: title, + start: date, + end: date, + url: `/build/${order.pk}/`, + backgroundColor: color, + }; + + calendar.addEvent(event); + } + } + } + ); + } + $(table).inventreeTable({ method: 'get', formatNoMatches: function() { return '{% trans "No builds matching query" %}'; }, - url: options.url, + url: '{% url "api-build-list" %}', queryParams: filters, groupBy: false, sidePagination: 'server', name: 'builds', original: params, + treeEnable: tree_enable, + uniqueId: 'pk', + rootParentId: options.parentBuild || null, + idField: 'pk', + parentIdField: 'parent', + treeShowField: tree_enable ? 'reference' : null, + showColumns: display_mode == 'list' || display_mode == 'tree', + showCustomView: display_mode == 'calendar', + showCustomViewButton: false, + disablePagination: display_mode == 'calendar', + search: display_mode != 'calendar', + buttons: constructOrderTableButtons({ + prefix: 'build', + callback: function() { + // Force complete reload of the table + loadBuildTable(table, options); + } + }), columns: [ { field: 'pk', @@ -2477,6 +2562,43 @@ function loadBuildTable(table, options) { } }, ], + customView: function(data) { + return `
`; + }, + onRefresh: function() { + loadBuildTable(table, options); + }, + onLoadSuccess: function() { + + if (tree_enable) { + $(table).treegrid({ + treeColumn: 1, + }); + + table.treegrid('expandAll'); + } else if (display_mode == 'calendar') { + + if (!loaded_calendar) { + loaded_calendar = true; + + var el = document.getElementById('build-order-calendar'); + + calendar = new FullCalendar.Calendar(el, { + initialView: 'dayGridMonth', + nowIndicator: true, + aspectRatio: 2.5, + locale: options.locale, + datesSet: function() { + buildEvents(calendar); + } + }); + + calendar.render(); + } else { + calendar.render(); + } + } + } }); linkButtonsToSelection( diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 03e967deee..4b39f11ee8 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -1504,7 +1504,8 @@ function removePurchaseOrderLineItem(e) { * Load a table displaying list of purchase orders */ function loadPurchaseOrderTable(table, options) { - /* Create a purchase-order table */ + // Ensure the table starts in a known state + $(table).bootstrapTable('destroy'); options.params = options.params || {}; @@ -1520,6 +1521,71 @@ function loadPurchaseOrderTable(table, options) { setupFilterList('purchaseorder', $(table), target, {download: true}); + var display_mode = inventreeLoad('purchaseorder-table-display-mode', 'list'); + + // Function for rendering PurchaseOrder calendar display + function buildEvents(calendar) { + + var start = startDate(calendar); + var end = endDate(calendar); + + clearEvents(calendar); + + // Extract current filters from table + var table_options = $(table).bootstrapTable('getOptions'); + var filters = table_options.query_params || {}; + + filters.supplier_detail = true; + filters.min_date = start; + filters.max_date = end; + + // Request purchase orders from the server within specified date range + inventreeGet( + '{% url "api-po-list" %}', + filters, + { + success: function(response) { + var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX; + + for (var idx = 0; idx < response.length; idx++) { + + var order = response[idx]; + + var date = order.creation_date; + + if (order.complete_date) { + date = order.complete_date; + } else if (order.target_date) { + date = order.target_date; + } + + var title = `${prefix}${order.reference} - ${order.supplier_detail.name}`; + + var color = '#4c68f5'; + + if (order.complete_date) { + color = '#25c235'; + } else if (order.overdue) { + color = '#c22525'; + } else { + color = '#4c68f5'; + } + + var event = { + title: title, + start: date, + end: date, + url: `/order/purchase-order/${order.pk}/`, + backgroundColor: color, + }; + + calendar.addEvent(event); + } + } + } + ); + } + $(table).inventreeTable({ url: '{% url "api-po-list" %}', queryParams: filters, @@ -1527,9 +1593,22 @@ function loadPurchaseOrderTable(table, options) { groupBy: false, sidePagination: 'server', original: options.params, + showColumns: display_mode == 'list', + disablePagination: display_mode == 'calendar', + showCustomViewButton: false, + showCustomView: display_mode == 'calendar', + search: display_mode != 'calendar', formatNoMatches: function() { return '{% trans "No purchase orders found" %}'; }, + buttons: constructOrderTableButtons({ + prefix: 'purchaseorder', + disableTreeView: true, + callback: function() { + // Reload the entire table + loadPurchaseOrderTable(table, options); + } + }), columns: [ { title: '', @@ -1629,6 +1708,30 @@ function loadPurchaseOrderTable(table, options) { } }, ], + customView: function(data) { + return `
`; + }, + onRefresh: function() { + loadPurchaseOrderTable(table, options); + }, + onLoadSuccess: function() { + + if (display_mode == 'calendar') { + var el = document.getElementById('purchase-order-calendar'); + + calendar = new FullCalendar.Calendar(el, { + initialView: 'dayGridMonth', + nowIndicator: true, + aspectRatio: 2.5, + locale: options.locale, + datesSet: function() { + buildEvents(calendar); + } + }); + + calendar.render(); + } + } }); } @@ -2191,6 +2294,9 @@ function loadPurchaseOrderExtraLineTable(table, options={}) { */ function loadSalesOrderTable(table, options) { + // Ensure the table starts in a known state + $(table).bootstrapTable('destroy'); + options.params = options.params || {}; options.params['customer_detail'] = true; @@ -2206,6 +2312,70 @@ function loadSalesOrderTable(table, options) { setupFilterList('salesorder', $(table), target, {download: true}); + var display_mode = inventreeLoad('salesorder-table-display-mode', 'list'); + + function buildEvents(calendar) { + + var start = startDate(calendar); + var end = endDate(calendar); + + clearEvents(calendar); + + // Extract current filters from table + var table_options = $(table).bootstrapTable('getOptions'); + var filters = table_options.query_params || {}; + + filters.customer_detail = true; + filters.min_date = start; + filters.max_date = end; + + // Request orders from the server within specified date range + inventreeGet( + '{% url "api-so-list" %}', + filters, + { + success: function(response) { + + var prefix = global_settings.SALESORDER_REFERENCE_PREFIX; + + for (var idx = 0; idx < response.length; idx++) { + var order = response[idx]; + + var date = order.creation_date; + + if (order.shipment_date) { + date = order.shipment_date; + } else if (order.target_date) { + date = order.target_date; + } + + var title = `${prefix}${order.reference} - ${order.customer_detail.name}`; + + // Default color is blue + var color = '#4c68f5'; + + // Overdue orders are red + if (order.overdue) { + color = '#c22525'; + } else if (order.status == {{ SalesOrderStatus.SHIPPED }}) { + color = '#25c235'; + } + + var event = { + title: title, + start: date, + end: date, + url: `/order/sales-order/${order.pk}/`, + backgroundColor: color, + }; + + calendar.addEvent(event); + } + } + } + ); + } + $(table).inventreeTable({ url: options.url, queryParams: filters, @@ -2213,9 +2383,46 @@ function loadSalesOrderTable(table, options) { groupBy: false, sidePagination: 'server', original: options.params, + showColums: display_mode != 'calendar', + search: display_mode != 'calendar', + showCustomViewButton: false, + showCustomView: display_mode == 'calendar', + disablePagination: display_mode == 'calendar', formatNoMatches: function() { return '{% trans "No sales orders found" %}'; }, + buttons: constructOrderTableButtons({ + prefix: 'salesorder', + disableTreeView: true, + callback: function() { + // Reload the entire table + loadSalesOrderTable(table, options); + }, + }), + customView: function(data) { + return `
`; + }, + onRefresh: function() { + loadPurchaseOrderTable(table, options); + }, + onLoadSuccess: function() { + + if (display_mode == 'calendar') { + var el = document.getElementById('purchase-order-calendar'); + + calendar = new FullCalendar.Calendar(el, { + initialView: 'dayGridMonth', + nowIndicator: true, + aspectRatio: 2.5, + locale: options.locale, + datesSet: function() { + buildEvents(calendar); + } + }); + + calendar.render(); + } + }, columns: [ { title: '', diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js index 8f25372f55..7525620bb3 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -12,6 +12,7 @@ reloadtable, renderLink, reloadTableFilters, + constructOrderTableButtons, */ /** @@ -24,7 +25,80 @@ function reloadtable(table) { /* - * Return the 'selected' data rows from a bootstrap table. + * Construct a set of extra buttons to display against a list of orders, + * allowing the orders to be displayed in various 'view' modes: + * + * - Calendar view + * - List view + * - Tree view + * + * Options: + * - callback: Callback function to be called when one of the buttons is pressed + * - prefix: The prefix to use when saving display data to user session + * - display: Which button to set as 'active' by default + * + */ +function constructOrderTableButtons(options={}) { + + var display_mode = options.display; + + var key = `${options.prefix || order}-table-display-mode`; + + // If display mode is not provided, look up from session + if (!display_mode) { + display_mode = inventreeLoad(key, 'list'); + } + + var idx = 0; + var buttons = []; + + function buttonCallback(view_mode) { + inventreeSave(key, view_mode); + + if (options.callback) { + options.callback(view_mode); + } + } + + var class_calendar = display_mode == 'calendar' ? 'btn-secondary' : 'btn-outline-secondary'; + var class_list = display_mode == 'list' ? 'btn-secondary' : 'btn-outline-secondary'; + var class_tree = display_mode == 'tree' ? 'btn-secondary' : 'btn-outline-secondary'; + + // Calendar view button + if (!options.disableCalendarView) { + buttons.push({ + html: ``, + event: function() { + buttonCallback('calendar'); + } + }); + } + + // List view button + if (!options.disableListView) { + buttons.push({ + html: ``, + event: function() { + buttonCallback('list'); + } + }); + } + + // Tree view button + if (!options.disableTreeView) { + buttons.push({ + html: ``, + event: function() { + buttonCallback('tree'); + } + }); + } + + return buttons; +} + + +/* Return the 'selected' data rows from a bootstrap table. * If allowEmpty = false, and the returned dataset is empty, * then instead try to return *all* the data */ From 7ccc8ad10d589bf81bc2524a00d7a70dcddcb65e Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 26 May 2022 18:23:43 +1000 Subject: [PATCH 05/16] Update version.py Bump version number to indicate dev candidate for 0.8.0 --- InvenTree/InvenTree/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 7d9cbad102..f66f5c8454 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,7 +12,7 @@ import common.models from InvenTree.api_version import INVENTREE_API_VERSION # InvenTree software version -INVENTREE_SW_VERSION = "0.7.0 dev" +INVENTREE_SW_VERSION = "0.8Bum.0 dev" def inventreeInstanceName(): From d1c92a9eda6f9ba76485301684624fd08a29eab7 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 26 May 2022 18:24:01 +1000 Subject: [PATCH 06/16] Update version.py Fix typo? --- InvenTree/InvenTree/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index f66f5c8454..f1190ec7ba 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,7 +12,7 @@ import common.models from InvenTree.api_version import INVENTREE_API_VERSION # InvenTree software version -INVENTREE_SW_VERSION = "0.8Bum.0 dev" +INVENTREE_SW_VERSION = "0.8.0 dev" def inventreeInstanceName(): From dcffd9a3cf0623da9abe7d2e87c743f919736840 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 27 May 2022 03:04:40 +0200 Subject: [PATCH 07/16] Add codeowners (#3079) * Add codeowners * use direct user handles --- .github/CODEOWNERS | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..e87a705e10 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,6 @@ +# General owner is the maintainers team +* @SchrodingersGat + +# plugins are co-owned +/InvenTree/plugin/ @SchrodingersGat @matmair +/InvenTree/plugins/ @SchrodingersGat @matmair From 640a5d0f243129ac4a91034ccbcef9f5a90b27be Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 27 May 2022 13:26:37 +1000 Subject: [PATCH 08/16] Improve error management for order price calculation (#3075) * Improve error management for order price calculation - If there are missing exchange rates, it throws an error - Very much an edge case * Style fixes * Add warning message if total order price cannot be calculated * price -> cost --- InvenTree/order/models.py | 75 +++++++++++++++++-- .../order/templates/order/order_base.html | 10 ++- .../templates/order/sales_order_base.html | 10 ++- 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 0a5f05ba0f..e4ad0b881f 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -4,7 +4,10 @@ Order model definitions # -*- coding: utf-8 -*- +import logging import os +import sys +import traceback from datetime import datetime from decimal import Decimal @@ -20,7 +23,9 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from djmoney.contrib.exchange.models import convert_money +from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.money import Money +from error_report.models import Error from markdownx.models import MarkdownxField from mptt.models import TreeForeignKey @@ -39,6 +44,9 @@ from stock import models as stock_models from users import models as UserModels +logger = logging.getLogger('inventree') + + def get_next_po_number(): """ Returns the next available PurchaseOrder reference number @@ -151,23 +159,74 @@ class Order(MetadataMixin, ReferenceIndexingMixin): notes = MarkdownxField(blank=True, verbose_name=_('Notes'), help_text=_('Order notes')) - def get_total_price(self): + def get_total_price(self, target_currency=currency_code_default()): """ - Calculates the total price of all order lines + Calculates the total price of all order lines, and converts to the specified target currency. + + If not specified, the default system currency is used. + + If currency conversion fails (e.g. there are no valid conversion rates), + then we simply return zero, rather than attempting some other calculation. """ - target_currency = currency_code_default() + total = Money(0, target_currency) # gather name reference - price_ref = 'sale_price' if isinstance(self, SalesOrder) else 'purchase_price' - # order items - total += sum(a.quantity * convert_money(getattr(a, price_ref), target_currency) for a in self.lines.all() if getattr(a, price_ref)) + price_ref_tag = 'sale_price' if isinstance(self, SalesOrder) else 'purchase_price' - # extra lines - total += sum(a.quantity * convert_money(a.price, target_currency) for a in self.extra_lines.all() if a.price) + # order items + for line in self.lines.all(): + + price_ref = getattr(line, price_ref_tag) + + if not price_ref: + continue + + try: + total += line.quantity * convert_money(price_ref, target_currency) + except MissingRate: + # Record the error, try to press on + kind, info, data = sys.exc_info() + + Error.objects.create( + kind=kind.__name__, + info=info, + data='\n'.join(traceback.format_exception(kind, info, data)), + path='order.get_total_price', + ) + + logger.error(f"Missing exchange rate for '{target_currency}'") + + # Return None to indicate the calculated price is invalid + return None + + # extra items + for line in self.extra_lines.all(): + + if not line.price: + continue + + try: + total += line.quantity * convert_money(line.price, target_currency) + except MissingRate: + # Record the error, try to press on + kind, info, data = sys.exc_info() + + Error.objects.create( + kind=kind.__name__, + info=info, + data='\n'.join(traceback.format_exception(kind, info, data)), + path='order.get_total_price', + ) + + logger.error(f"Missing exchange rate for '{target_currency}'") + + # Return None to indicate the calculated price is invalid + return None # set decimal-places total.decimal_places = 4 + return total diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 1e0de4d193..a14fea16f7 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -181,7 +181,15 @@ src="{% static 'img/blank_image.png' %}" {% trans "Total cost" %} - {{ order.get_total_price }} + + {% with order.get_total_price as tp %} + {% if tp == None %} + {% trans "Total cost could not be calculated" %} + {% else %} + {{ tp }} + {% endif %} + {% endwith %} + {% endblock %} diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 1e7ddc168f..131280ac7a 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -188,7 +188,15 @@ src="{% static 'img/blank_image.png' %}" {% trans "Total cost" %} - {{ order.get_total_price }} + + {% with order.get_total_price as tp %} + {% if tp == None %} + {% trans "Total cost could not be calculated" %} + {% else %} + {{ tp }} + {% endif %} + {% endwith %} + {% endblock %} From 6c7a80c141ea85a495a92941f3e66e8e438bbda3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 27 May 2022 13:26:45 +1000 Subject: [PATCH 09/16] Check user permissions before performing search (#3083) * Check user permissions before performing search * JS linting --- InvenTree/templates/js/translated/search.js | 59 ++++++++++++++++++--- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/InvenTree/templates/js/translated/search.js b/InvenTree/templates/js/translated/search.js index 9cc0fd83bf..5af1d899b4 100644 --- a/InvenTree/templates/js/translated/search.js +++ b/InvenTree/templates/js/translated/search.js @@ -17,6 +17,41 @@ function closeSearchPanel() { } +// Keep track of the roles / permissions available to the current user +var search_user_roles = null; + + +/* + * Check if the user has the specified role and permission + */ +function checkPermission(role, permission='view') { + + if (!search_user_roles) { + return false; + } + + if (!(role in search_user_roles)) { + return false; + } + + var roles = search_user_roles[role]; + + if (!roles) { + return false; + } + + var found = false; + + search_user_roles[role].forEach(function(p) { + if (String(p).valueOf() == String(permission).valueOf()) { + found = true; + } + }); + + return found; +} + + /* * Callback when the search panel is opened. * Ensure the panel is in a known state @@ -27,6 +62,16 @@ function openSearchPanel() { clearSearchResults(); + // Request user roles if we do not have them + if (search_user_roles == null) { + inventreeGet('{% url "api-user-roles" %}', {}, { + success: function(response) { + search_user_roles = response.roles || {}; + } + }); + } + + // Callback for text input changed panel.find('#search-input').on('keyup change', searchTextChanged); // Callback for "clear search" button @@ -84,7 +129,7 @@ function updateSearch() { // Show the "searching" text $('#offcanvas-search').find('#search-pending').show(); - if (user_settings.SEARCH_PREVIEW_SHOW_PARTS) { + if (checkPermission('part') && user_settings.SEARCH_PREVIEW_SHOW_PARTS) { var params = {}; @@ -106,7 +151,7 @@ function updateSearch() { ); } - if (user_settings.SEARCH_PREVIEW_SHOW_CATEGORIES) { + if (checkPermission('part_category') && user_settings.SEARCH_PREVIEW_SHOW_CATEGORIES) { // Search for matching part categories addSearchQuery( 'category', @@ -120,7 +165,7 @@ function updateSearch() { ); } - if (user_settings.SEARCH_PREVIEW_SHOW_STOCK) { + if (checkPermission('stock') && user_settings.SEARCH_PREVIEW_SHOW_STOCK) { // Search for matching stock items var filters = { @@ -146,7 +191,7 @@ function updateSearch() { ); } - if (user_settings.SEARCH_PREVIEW_SHOW_LOCATIONS) { + if (checkPermission('stock_location') && user_settings.SEARCH_PREVIEW_SHOW_LOCATIONS) { // Search for matching stock locations addSearchQuery( 'location', @@ -160,7 +205,7 @@ function updateSearch() { ); } - if (user_settings.SEARCH_PREVIEW_SHOW_COMPANIES) { + if ((checkPermission('sales_order') || checkPermission('purchase_order')) && user_settings.SEARCH_PREVIEW_SHOW_COMPANIES) { // Search for matching companies addSearchQuery( 'company', @@ -174,7 +219,7 @@ function updateSearch() { ); } - if (user_settings.SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS) { + if (checkPermission('purchase_order') && user_settings.SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS) { var filters = { supplier_detail: true, @@ -197,7 +242,7 @@ function updateSearch() { ); } - if (user_settings.SEARCH_PREVIEW_SHOW_SALES_ORDERS) { + if (checkPermission('sales_order') && user_settings.SEARCH_PREVIEW_SHOW_SALES_ORDERS) { var filters = { customer_detail: true, From 09a4fab0d64c89f50da8a38189a102f32ae8fcd3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 27 May 2022 15:12:15 +1000 Subject: [PATCH 10/16] Adds 'quarantine' code for StockItemStatus (#3084) * Adds 'quarantine' code for StockItemStatus - Marks item as "unavailable" - Removes unused code from StockItemStatus class * Add migration file --- InvenTree/InvenTree/status_codes.py | 23 +++---------------- .../migrations/0076_alter_stockitem_status.py | 19 +++++++++++++++ 2 files changed, 22 insertions(+), 20 deletions(-) create mode 100644 InvenTree/stock/migrations/0076_alter_stockitem_status.py diff --git a/InvenTree/InvenTree/status_codes.py b/InvenTree/InvenTree/status_codes.py index 57bacc861e..15f3d872bb 100644 --- a/InvenTree/InvenTree/status_codes.py +++ b/InvenTree/InvenTree/status_codes.py @@ -173,12 +173,9 @@ class StockStatus(StatusCode): DESTROYED = 60 # Item is destroyed REJECTED = 65 # Item is rejected LOST = 70 # Item has been lost + QUARANTINED = 75 # Item has been quarantined and is unavailable RETURNED = 85 # Item has been returned from a customer - # Any stock code above 100 means that the stock item is not "in stock" - # This can be used as a quick check for filtering - NOT_IN_STOCK = 100 - options = { OK: _("OK"), ATTENTION: _("Attention needed"), @@ -186,6 +183,7 @@ class StockStatus(StatusCode): DESTROYED: _("Destroyed"), LOST: _("Lost"), REJECTED: _("Rejected"), + QUARANTINED: _("Quarantined"), RETURNED: _("Returned"), } @@ -196,6 +194,7 @@ class StockStatus(StatusCode): DESTROYED: 'danger', LOST: 'dark', REJECTED: 'danger', + QUARANTINED: 'info' } # The following codes correspond to parts that are 'available' or 'in stock' @@ -206,22 +205,6 @@ class StockStatus(StatusCode): RETURNED, ] - # The following codes correspond to parts that are 'unavailable' - UNAVAILABLE_CODES = [ - DESTROYED, - LOST, - REJECTED, - ] - - # The following codes are available for receiving goods - RECEIVING_CODES = [ - OK, - ATTENTION, - DAMAGED, - DESTROYED, - REJECTED - ] - class StockHistoryCode(StatusCode): diff --git a/InvenTree/stock/migrations/0076_alter_stockitem_status.py b/InvenTree/stock/migrations/0076_alter_stockitem_status.py new file mode 100644 index 0000000000..625fa372b6 --- /dev/null +++ b/InvenTree/stock/migrations/0076_alter_stockitem_status.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.13 on 2022-05-27 04:40 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0075_auto_20220515_1440'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitem', + name='status', + field=models.PositiveIntegerField(choices=[(10, 'OK'), (50, 'Attention needed'), (55, 'Damaged'), (60, 'Destroyed'), (70, 'Lost'), (65, 'Rejected'), (75, 'Quarantined'), (85, 'Returned')], default=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] From 4d8836378b0d55eeeb5af6c63d6775afa375f7d8 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sat, 28 May 2022 02:38:12 +0200 Subject: [PATCH 11/16] CI updates (#3087) * Add pre-commit to the stack * exclude static * Add locales to excludes * fix style errors * rename pipeline steps * also wait on precommit * make template matching simpler * Use the same code for python setup everywhere * use step and cache for python setup * move regular settings up into general envs * just use full update * Use invoke instead of static references * make setup actions more similar * use python3 * refactor names to be similar * fix runner version * fix references * remove incidential change * use matrix for os * Github can't do this right now * ignore docstyle errors * Add seperate docstring test * update flake call * do not fail on docstring * refactor setup into workflow * update reference * switch to action * resturcture * add bash statements * remove os from cache * update input checks * make code cleaner * fix boolean * no relative paths * install wheel by python * switch to install * revert back to simple wheel * refactor import export tests * move setup keys back to not disturbe tests * remove docstyle till that is fixed * update references * continue on error * use relativ action references * Change step / job docstrings --- .github/actions/migration/action.yaml | 17 ++ .github/actions/setup/action.yaml | 82 ++++++ .github/workflows/qc_checks.yaml | 267 +++++++----------- .pre-commit-config.yaml | 5 + InvenTree/order/models.py | 3 +- .../stock/templates/stock/item_base.html | 4 +- InvenTree/templates/js/translated/build.js | 4 +- InvenTree/templates/js/translated/order.js | 14 +- InvenTree/templates/js/translated/tables.js | 10 +- tasks.py | 6 +- 10 files changed, 221 insertions(+), 191 deletions(-) create mode 100644 .github/actions/migration/action.yaml create mode 100644 .github/actions/setup/action.yaml diff --git a/.github/actions/migration/action.yaml b/.github/actions/migration/action.yaml new file mode 100644 index 0000000000..3270a9e55b --- /dev/null +++ b/.github/actions/migration/action.yaml @@ -0,0 +1,17 @@ +name: 'Migration test' +description: 'Run migration test sequenze' +author: 'inventree' + +runs: + using: 'composite' + steps: + - name: Data Import Export + shell: bash + run: | + invoke migrate + invoke import-fixtures + invoke export-records -f data.json + python3 ./InvenTree/manage.py flush --noinput + invoke migrate + invoke import-records -f data.json + invoke import-records -f data.json diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml new file mode 100644 index 0000000000..1a954349ef --- /dev/null +++ b/.github/actions/setup/action.yaml @@ -0,0 +1,82 @@ +name: 'Setup Enviroment' +description: 'Setup the enviroment for general InvenTree tests' +author: 'inventree' +inputs: + python: + required: false + description: 'Install python.' + default: 'true' + npm: + required: false + description: 'Install npm.' + default: 'false' + + install: + required: false + description: 'Install the InvenTree requirements?' + default: 'false' + update: + required: false + description: 'Should a full update cycle be run?' + default: 'false' + + apt-dependency: + required: false + description: 'Extra APT package for install.' + pip-dependency: + required: false + description: 'Extra python package for install.' + +runs: + using: 'composite' + steps: + - name: Checkout Code + uses: actions/checkout@v2 + + # Python installs + - name: Set up Python ${{ env.python_version }} + if: ${{ inputs.python == 'true' }} + uses: actions/setup-python@v2 + with: + python-version: ${{ env.python_version }} + cache: pip + - name: Install Base Python Dependencies + if: ${{ inputs.python == 'true' }} + shell: bash + run: | + python3 -m pip install -U pip + pip3 install invoke wheel + - name: Install Specific Python Dependencies + if: ${{ inputs.pip-dependency }} + shell: bash + run: pip3 install ${{ inputs.pip-dependency }} + + # NPM installs + - name: Install node.js ${{ env.node_version }} + if: ${{ inputs.npm == 'true' }} + uses: actions/setup-node@v2 + with: + node-version: ${{ env.node_version }} + cache: 'npm' + - name: Intall npm packages + if: ${{ inputs.npm == 'true' }} + shell: bash + run: npm install + + # OS installs + - name: Install OS Dependencies + if: ${{ inputs.apt-dependency }} + shell: bash + run: | + sudo apt-get update + sudo apt-get install ${{ inputs.apt-dependency }} + + # Invoke commands + - name: Run invoke install + if: ${{ inputs.install == 'true' }} + shell: bash + run: invoke install + - name: Run invoke update + if: ${{ inputs.update == 'true' }} + shell: bash + run: invoke update diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 2245a5242f..cf2700c3d3 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -14,120 +14,94 @@ on: env: python_version: 3.9 node_version: 16 + # The OS version must be set per job server_start_sleep: 60 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} INVENTREE_DB_ENGINE: sqlite3 INVENTREE_DB_NAME: inventree - INVENTREE_MEDIA_ROOT: ./media - INVENTREE_STATIC_ROOT: ./static - + INVENTREE_MEDIA_ROOT: ../test_inventree_media + INVENTREE_STATIC_ROOT: ../test_inventree_static jobs: pep_style: - name: PEP style (python) - runs-on: ubuntu-latest + name: Style [Python] + runs-on: ubuntu-20.04 steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Set up Python ${{ env.python_version }} - uses: actions/setup-python@v2 + - uses: actions/checkout@v1 + - name: Enviroment Setup + uses: ./.github/actions/setup with: - python-version: ${{ env.python_version }} - cache: 'pip' - - name: Install deps - run: | - pip install flake8==3.8.3 - pip install pep8-naming==0.11.1 - - name: flake8 - run: | - flake8 InvenTree + install: true + - name: Run flake8 + run: flake8 InvenTree --extend-ignore=D javascript: - name: javascript template files + name: Style [JS] + runs-on: ubuntu-20.04 + needs: pep_style - runs-on: ubuntu-latest steps: - - name: Checkout Code - uses: actions/checkout@v2 - - name: Install node.js ${{ env.node_version }} - uses: actions/setup-node@v2 + - uses: actions/checkout@v1 + - name: Enviroment Setup + uses: ./.github/actions/setup with: - node-version: ${{ env.node_version }} - cache: 'npm' - - run: npm install - - name: Setup Python - uses: actions/setup-python@v2 - with: - python-version: ${{ env.python_version }} - cache: 'pip' - - name: Install Dependencies - run: | - sudo apt-get update - sudo apt-get install gettext - pip3 install invoke - invoke install - invoke static - - name: Check Templated Files + npm: true + install: true + - name: Check Templated JS Files run: | cd ci - python check_js_templates.py + python3 check_js_templates.py - name: Lint Javascript Files run: | invoke render-js-files npx eslint js_tmp/*.js html: - name: html template files + name: Style [HTML] + runs-on: ubuntu-20.04 + needs: pep_style - runs-on: ubuntu-latest steps: - - name: Checkout Code - uses: actions/checkout@v2 - - name: Install node.js ${{ env.node_version }} - uses: actions/setup-node@v2 + - uses: actions/checkout@v1 + - name: Enviroment Setup + uses: ./.github/actions/setup with: - node-version: ${{ env.node_version }} - cache: 'npm' - - run: npm install - - name: Setup Python - uses: actions/setup-python@v2 - with: - python-version: ${{ env.python_version }} - cache: 'pip' - - name: Install Dependencies - run: | - sudo apt-get update - sudo apt-get install gettext - pip3 install invoke - invoke install - invoke static + npm: true + install: true - name: Check HTML Files - run: | - npx markuplint InvenTree/build/templates/build/*.html - npx markuplint InvenTree/company/templates/company/*.html - npx markuplint InvenTree/order/templates/order/*.html - npx markuplint InvenTree/part/templates/part/*.html - npx markuplint InvenTree/stock/templates/stock/*.html - npx markuplint InvenTree/templates/*.html - npx markuplint InvenTree/templates/InvenTree/*.html - npx markuplint InvenTree/templates/InvenTree/settings/*.html + run: npx markuplint **/templates/*.html + + pre-commit: + name: Style [pre-commit] + runs-on: ubuntu-20.04 + + needs: pep_style + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ env.python_version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ env.python_version }} + cache: 'pip' + - name: Run pre-commit Checks + uses: pre-commit/action@v2.0.3 python: - name: python bindings - needs: pep_style - runs-on: ubuntu-latest + name: Tests - inventree-python + runs-on: ubuntu-20.04 + + needs: pre-commit env: wrapper_name: inventree-python INVENTREE_DB_ENGINE: django.db.backends.sqlite3 INVENTREE_DB_NAME: ../inventree_unit_test_db.sqlite3 - INVENTREE_MEDIA_ROOT: ../test_inventree_media - INVENTREE_STATIC_ROOT: ../test_inventree_static INVENTREE_ADMIN_USER: testuser INVENTREE_ADMIN_PASSWORD: testpassword INVENTREE_ADMIN_EMAIL: test@test.com @@ -136,34 +110,32 @@ jobs: INVENTREE_PYTHON_TEST_PASSWORD: testpassword steps: - - name: Checkout Code - uses: actions/checkout@v2 - - name: Install InvenTree - run: | - sudo apt-get update - sudo apt-get install python3-dev python3-pip python3-venv - pip3 install invoke - invoke install - invoke migrate - - name: Download Python Code - run: | - git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }} ./${{ env.wrapper_name }} - - name: Start Server + - uses: actions/checkout@v1 + - name: Enviroment Setup + uses: ./.github/actions/setup + with: + apt-dependency: gettext + update: true + - name: Download Python Code For `${{ env.wrapper_name }}` + run: git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }} ./${{ env.wrapper_name }} + - name: Start InvenTree Server run: | invoke delete-data -f invoke import-fixtures invoke server -a 127.0.0.1:12345 & invoke wait - - name: Run Tests + - name: Run Tests For `${{ env.wrapper_name }}` run: | cd ${{ env.wrapper_name }} invoke check-server coverage run -m unittest discover -s test/ coverage: - name: Sqlite / coverage - needs: ['javascript', 'html'] - runs-on: ubuntu-latest + name: Tests - DB [SQLite] + Coverage + runs-on: ubuntu-20.04 + + needs: ['javascript', 'html', 'pre-commit'] + continue-on-error: true # continue if a step fails so that coverage gets pushed env: INVENTREE_DB_NAME: ./inventree.sqlite @@ -171,32 +143,16 @@ jobs: INVENTREE_PLUGINS_ENABLED: true steps: - - name: Checkout Code - uses: actions/checkout@v2 - - name: Setup Python ${{ env.python_version }} - uses: actions/setup-python@v2 + - uses: actions/checkout@v1 + - name: Enviroment Setup + uses: ./.github/actions/setup with: - python-version: ${{ env.python_version }} - cache: 'pip' - - name: Install Dependencies - run: | - sudo apt-get update - sudo apt-get install gettext - python -m pip install -U pip - pip3 install invoke - invoke update + apt-dependency: gettext + update: true - name: Coverage Tests - run: | - invoke coverage - - name: Data Import Export - run: | - invoke migrate - invoke import-fixtures - invoke export-records -f data.json - rm inventree.sqlite - invoke migrate - invoke import-records -f data.json - invoke import-records -f data.json + run: invoke coverage + - name: Data Export Test + uses: ./.github/actions/migration - name: Test Translations run: invoke translate - name: Check Migration Files @@ -205,9 +161,10 @@ jobs: run: coveralls postgres: - name: Postgres - needs: ['javascript', 'html'] - runs-on: ubuntu-latest + name: Tests - DB [PostgreSQL] + runs-on: ubuntu-20.04 + + needs: ['javascript', 'html', 'pre-commit'] if: github.event_name == 'push' env: @@ -235,38 +192,23 @@ jobs: - 6379:6379 steps: - - name: Checkout Code - uses: actions/checkout@v2 - - name: Setup Python ${{ env.python_version }} - uses: actions/setup-python@v2 + - uses: actions/checkout@v1 + - name: Enviroment Setup + uses: ./.github/actions/setup with: - python-version: ${{ env.python_version }} - cache: 'pip' - - name: Install Dependencies - run: | - sudo apt-get update - sudo apt-get install libpq-dev gettext - python -m pip install -U pip - pip3 install invoke - pip3 install psycopg2 - pip3 install django-redis>=5.0.0 - invoke update + apt-dependency: gettext libpq-dev + pip-dependency: psycopg2 django-redis>=5.0.0 + update: true - name: Run Tests run: invoke test - - name: Data Import Export - run: | - invoke migrate - python3 ./InvenTree/manage.py flush --noinput - invoke import-fixtures - invoke export-records -f data.json - python3 ./InvenTree/manage.py flush --noinput - invoke import-records -f data.json - invoke import-records -f data.json + - name: Data Export Test + uses: ./.github/actions/migration mysql: - name: MySql - needs: ['javascript', 'html'] - runs-on: ubuntu-latest + name: Tests - DB [MySQL] + runs-on: ubuntu-20.04 + + needs: ['javascript', 'html', 'pre-commit'] if: github.event_name == 'push' env: @@ -293,29 +235,14 @@ jobs: - 3306:3306 steps: - - name: Checkout Code - uses: actions/checkout@v2 - - name: Setup Python ${{ env.python_version }} - uses: actions/setup-python@v2 + - uses: actions/checkout@v1 + - name: Enviroment Setup + uses: ./.github/actions/setup with: - python-version: ${{ env.python_version }} - cache: 'pip' - - name: Install Dependencies - run: | - sudo apt-get update - sudo apt-get install libmysqlclient-dev gettext - python -m pip install -U pip - pip3 install invoke - pip3 install mysqlclient - invoke update + apt-dependency: gettext libmysqlclient-dev + pip-dependency: mysqlclient + update: true - name: Run Tests run: invoke test - - name: Data Import Export - run: | - invoke migrate - python3 ./InvenTree/manage.py flush --noinput - invoke import-fixtures - invoke export-records -f data.json - python3 ./InvenTree/manage.py flush --noinput - invoke import-records -f data.json - invoke import-records -f data.json + - name: Data Export Test + uses: ./.github/actions/migration diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1dc2ba3f70..269ddddee9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,10 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks +exclude: | + (?x)^( + InvenTree/InvenTree/static/.*| + InvenTree/locale/.* + )$ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.2.0 diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index e4ad0b881f..3871ba38a1 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -22,8 +22,8 @@ from django.dispatch.dispatcher import receiver from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.exceptions import MissingRate +from djmoney.contrib.exchange.models import convert_money from djmoney.money import Money from error_report.models import Error from markdownx.models import MarkdownxField @@ -43,7 +43,6 @@ from plugin.models import MetadataMixin from stock import models as stock_models from users import models as UserModels - logger = logging.getLogger('inventree') diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 42c5df622d..9ae43c1c4b 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -156,7 +156,7 @@ {% endif %} - + {% if item.uid %} @@ -452,7 +452,7 @@ {% endif %} - + {% endblock details_right %} diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 7d7cb59240..b17c6282a0 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -2582,7 +2582,7 @@ function loadBuildTable(table, options) { loaded_calendar = true; var el = document.getElementById('build-order-calendar'); - + calendar = new FullCalendar.Calendar(el, { initialView: 'dayGridMonth', nowIndicator: true, @@ -2592,7 +2592,7 @@ function loadBuildTable(table, options) { buildEvents(calendar); } }); - + calendar.render(); } else { calendar.render(); diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 4b39f11ee8..9b4903b22b 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -1522,7 +1522,7 @@ function loadPurchaseOrderTable(table, options) { setupFilterList('purchaseorder', $(table), target, {download: true}); var display_mode = inventreeLoad('purchaseorder-table-display-mode', 'list'); - + // Function for rendering PurchaseOrder calendar display function buildEvents(calendar) { @@ -1538,7 +1538,7 @@ function loadPurchaseOrderTable(table, options) { filters.supplier_detail = true; filters.min_date = start; filters.max_date = end; - + // Request purchase orders from the server within specified date range inventreeGet( '{% url "api-po-list" %}', @@ -1718,7 +1718,7 @@ function loadPurchaseOrderTable(table, options) { if (display_mode == 'calendar') { var el = document.getElementById('purchase-order-calendar'); - + calendar = new FullCalendar.Calendar(el, { initialView: 'dayGridMonth', nowIndicator: true, @@ -1728,7 +1728,7 @@ function loadPurchaseOrderTable(table, options) { buildEvents(calendar); } }); - + calendar.render(); } } @@ -2409,7 +2409,7 @@ function loadSalesOrderTable(table, options) { if (display_mode == 'calendar') { var el = document.getElementById('purchase-order-calendar'); - + calendar = new FullCalendar.Calendar(el, { initialView: 'dayGridMonth', nowIndicator: true, @@ -2419,7 +2419,7 @@ function loadSalesOrderTable(table, options) { buildEvents(calendar); } }); - + calendar.render(); } }, @@ -2881,7 +2881,7 @@ function allocateStockToSalesOrder(order_id, line_items, options={}) { fields.reference.value = ref; fields.reference.prefix = global_settings.SALESORDER_REFERENCE_PREFIX + options.reference; - + return fields; } } diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js index 7525620bb3..fcbaba7336 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -27,16 +27,16 @@ function reloadtable(table) { /* * Construct a set of extra buttons to display against a list of orders, * allowing the orders to be displayed in various 'view' modes: - * + * * - Calendar view * - List view * - Tree view - * + * * Options: * - callback: Callback function to be called when one of the buttons is pressed * - prefix: The prefix to use when saving display data to user session * - display: Which button to set as 'active' by default - * + * */ function constructOrderTableButtons(options={}) { @@ -51,10 +51,10 @@ function constructOrderTableButtons(options={}) { var idx = 0; var buttons = []; - + function buttonCallback(view_mode) { inventreeSave(key, view_mode); - + if (options.callback) { options.callback(view_mode); } diff --git a/tasks.py b/tasks.py index a5d1ac0e5b..9fa55c4513 100644 --- a/tasks.py +++ b/tasks.py @@ -554,9 +554,9 @@ def test_translations(c): # complie regex reg = re.compile( - r"[a-zA-Z0-9]{1}"+ # match any single letter and number - r"(?![^{\(\<]*[}\)\>])"+ # that is not inside curly brackets, brackets or a tag - r"(?])" + # that is not inside curly brackets, brackets or a tag # noqa: W504 + r"(? Date: Sun, 29 May 2022 00:25:40 +0200 Subject: [PATCH 12/16] Add option to also reload plugin mechanisms (#3081) --- InvenTree/InvenTree/tests.py | 2 +- InvenTree/plugin/registry.py | 69 ++++++++++++++++++++++-------------- 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index f306cce32a..61130ad1b4 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -522,7 +522,7 @@ class TestSettings(helpers.InvenTreeTestCase): # Set dynamic setting to True and rerun to launch install InvenTreeSetting.set_setting('PLUGIN_ON_STARTUP', True, self.user) - registry.reload_plugins() + registry.reload_plugins(full_reload=True) # Check that there was anotehr run response = registry.install_plugin_file() diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 40e5bd3ff5..e8e61496b4 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -86,9 +86,11 @@ class PluginsRegistry: # region public functions # region loading / unloading - def load_plugins(self): - """ - Load and activate all IntegrationPlugins + def load_plugins(self, full_reload: bool = False): + """Load and activate all IntegrationPlugins + + Args: + full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. """ if not settings.PLUGINS_ENABLED: # Plugins not enabled, do nothing @@ -109,7 +111,7 @@ class PluginsRegistry: try: # We are using the db so for migrations etc we need to try this block self._init_plugins(blocked_plugin) - self._activate_plugins() + self._activate_plugins(full_reload=full_reload) registered_successful = True except (OperationalError, ProgrammingError): # pragma: no cover # Exception if the database has not been migrated yet @@ -123,7 +125,7 @@ class PluginsRegistry: # Initialize apps without any plugins self._clean_registry() self._clean_installed_apps() - self._activate_plugins(force_reload=True) + self._activate_plugins(force_reload=True, full_reload=full_reload) # We do not want to end in an endless loop retry_counter -= 1 @@ -137,6 +139,10 @@ class PluginsRegistry: # now the loading will re-start up with init + # disable full reload after the first round + if full_reload: + full_reload = False + # Remove maintenance mode if not _maintenance: set_maintenance_mode(False) @@ -170,9 +176,11 @@ class PluginsRegistry: set_maintenance_mode(False) # pragma: no cover logger.info('Finished unloading plugins') - def reload_plugins(self): - """ - Safely reload IntegrationPlugins + def reload_plugins(self, full_reload: bool = False): + """Safely reload IntegrationPlugins + + Args: + full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. """ # Do not reload whe currently loading @@ -183,7 +191,7 @@ class PluginsRegistry: with maintenance_mode_on(): self.unload_plugins() - self.load_plugins() + self.load_plugins(full_reload) logger.info('Finished reloading plugins') @@ -335,12 +343,12 @@ class PluginsRegistry: # save for later reference self.plugins_inactive[plug_key] = plugin_db_setting # pragma: no cover - def _activate_plugins(self, force_reload=False): - """ - Run activation functions for all plugins + def _activate_plugins(self, force_reload=False, full_reload: bool = False): + """Run activation functions for all plugins - :param force_reload: force reload base apps, defaults to False - :type force_reload: bool, optional + Args: + force_reload (bool, optional): Also reload base apps. Defaults to False. + full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. """ # activate integrations plugins = self.plugins.items() @@ -348,7 +356,7 @@ class PluginsRegistry: self.activate_plugin_settings(plugins) self.activate_plugin_schedule(plugins) - self.activate_plugin_app(plugins, force_reload=force_reload) + self.activate_plugin_app(plugins, force_reload=force_reload, full_reload=full_reload) def _deactivate_plugins(self): """Run deactivation functions for all plugins""" @@ -432,15 +440,15 @@ class PluginsRegistry: """ pass - def activate_plugin_app(self, plugins, force_reload=False): - """ - Activate AppMixin plugins - add custom apps and reload + def activate_plugin_app(self, plugins, force_reload=False, full_reload: bool = False): + """Activate AppMixin plugins - add custom apps and reload - :param plugins: list of IntegrationPlugins that should be installed - :type plugins: dict - :param force_reload: only reload base apps, defaults to False - :type force_reload: bool, optional + Args: + plugins (dict): List of IntegrationPlugins that should be installed + force_reload (bool, optional): Only reload base apps. Defaults to False. + full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. """ + from common.models import InvenTreeSetting if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP'): @@ -461,9 +469,9 @@ class PluginsRegistry: # first startup or force loading of base apps -> registry is prob false if self.apps_loading or force_reload: self.apps_loading = False - self._reload_apps(force_reload=True) + self._reload_apps(force_reload=True, full_reload=full_reload) else: - self._reload_apps() + self._reload_apps(full_reload=full_reload) # rediscover models/ admin sites self._reregister_contrib_apps() @@ -589,8 +597,17 @@ class PluginsRegistry: global_pattern[0] = re_path('', include(urlpatterns)) clear_url_caches() - def _reload_apps(self, force_reload: bool = False): - self.is_loading = True # set flag to disable loop reloading + def _reload_apps(self, force_reload: bool = False, full_reload: bool = False): + """Internal: reload apps using django internal functions + + Args: + force_reload (bool, optional): Also reload base apps. Defaults to False. + full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. + """ + + # If full_reloading is set to true we do not want to set the flag + if not full_reload: + self.is_loading = True # set flag to disable loop reloading if force_reload: # we can not use the built in functions as we need to brute force the registry apps.app_configs = OrderedDict() From b9fd263899253329a6e1cdae9a155120a1458f6a Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 29 May 2022 09:40:37 +1000 Subject: [PATCH 13/16] Docker improvements (#3042) * Simplified dockerfile - Changed from alpine to python:slim - Removed some database libs (because we *connect* to a db, not host it) * - Add gettext as required package - Only create inventree user as part of production build (leave admin access for dev build) * Tweaks for tasks.py * Fix user permissions (drop to inventree user) * Drop to the 'inventree' user level as part of init.sh - As we have mounted volumes at 'run time' we need to ensure that the inventree user has correct permissions! - Ref: https://stackoverflow.com/questions/39397548/how-to-give-non-root-user-in-docker-container-access-to-a-volume-mounted-on-the * Adjust user setup - Only drop to non-root user as part of "production" build - Mounted external volumes make it tricky when in the dev build - Might want to revisit this later on * More dockerfile changes - reduce required system packages - * Add new docker github workflow * Print some more debug * GITHUB_BASE_REF * Add gnupg to base requirements * Improve debug output during testing * Refactoring updates for label printing API - Update weasyprint version to 55.0 - Generate labels as pdf files - Provide filename to label printing plugin - Additional unit testing - Improve extraction of some hidden debug data during TESTING - Fix a spelling mistake (notifaction -> notification) * Working on github action * More testing * Add requirement for pdf2image * Fix label printing plugin and update unit testing * Add required packages for CI * Move docker files to the top level directory - This allows us to build the production image directly from soure - Don't need to re-download the source code from github - Note: The docker install guide will need to be updated! * Fix for docker ci file * Print GIT SHA * Bake git information into the production image * Add some exta docstrings to dockerfile * Simplify version check script * Extract git commit info * Extract docker tag from check_version.py * Newline * More work on the docker workflow * Dockerfile fixes - Directory / path issues * Dockerfile fixes - Directory / path issues * Ignore certain steps on a pull request * Add poppler-utils to CI * Consolidate version check into existing CI file * Don't run docker workflow on pull request * Pass docker image tag through to the build Also check .j2k files * Add supervisord.conf example file back in * Remove --no-cache-dir option from pip install --- docker/.env => .env | 1 + .github/workflows/docker.yaml | 69 ++++++++++ .github/workflows/docker_latest.yaml | 51 ------- .github/workflows/docker_stable.yaml | 42 ------ .github/workflows/docker_tag.yaml | 38 ------ .github/workflows/qc_checks.yaml | 11 +- .github/workflows/version.yml | 21 --- .gitignore | 5 +- docker/Dockerfile => Dockerfile | 125 ++++++++--------- InvenTree/InvenTree/api_tester.py | 5 + InvenTree/InvenTree/exceptions.py | 6 +- InvenTree/InvenTree/helpers.py | 2 +- InvenTree/InvenTree/tests.py | 24 ++++ InvenTree/InvenTree/version.py | 13 ++ InvenTree/common/notifications.py | 2 +- InvenTree/label/api.py | 17 +-- InvenTree/part/tasks.py | 2 +- InvenTree/part/test_api.py | 2 +- InvenTree/plugin/base/label/label.py | 51 ++++++- InvenTree/plugin/base/label/mixins.py | 15 ++- .../plugin/base/label/test_label_mixin.py | 20 ++- .../samples/integration/label_sample.py | 21 ++- ci/check_version_number.py | 127 ++++++++++-------- .../docker-compose.yml => docker-compose.yml | 2 +- docker/init.sh | 2 +- docker/production/.env | 6 + requirements.txt | 6 +- tasks.py | 4 +- 28 files changed, 376 insertions(+), 314 deletions(-) rename docker/.env => .env (89%) create mode 100644 .github/workflows/docker.yaml delete mode 100644 .github/workflows/docker_latest.yaml delete mode 100644 .github/workflows/docker_stable.yaml delete mode 100644 .github/workflows/docker_tag.yaml delete mode 100644 .github/workflows/version.yml rename docker/Dockerfile => Dockerfile (53%) rename docker/docker-compose.yml => docker-compose.yml (98%) diff --git a/docker/.env b/.env similarity index 89% rename from docker/.env rename to .env index 54e37ea7a0..586b0daab3 100644 --- a/docker/.env +++ b/.env @@ -1,4 +1,5 @@ # InvenTree environment variables for a development setup +# These variables will be used by the docker-compose.yml file # Set DEBUG to True for a development setup INVENTREE_DEBUG=True diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 0000000000..b9f895983d --- /dev/null +++ b/.github/workflows/docker.yaml @@ -0,0 +1,69 @@ +# Build, test and push InvenTree docker image +# This workflow runs under any of the following conditions: +# +# - Push to the master branch +# - Push to the stable branch +# - Publish release +# +# The following actions are performed: +# +# - Check that the version number matches the current branch or tag +# - Build the InvenTree docker image +# - Run suite of unit tests against the build image +# - Push the compiled, tested image to dockerhub + +name: Docker + +on: + release: + types: [published] + + push: + branches: + - 'master' + - 'stable' + +jobs: + + # Build the docker image + build: + runs-on: ubuntu-latest + + steps: + - name: Check out repo + uses: actions/checkout@v2 + - name: Version Check + run: | + python3 ci/check_version_number.py + echo "git_commit_hash=$(git rev-parse --short HEAD)" >> $GITHUB_ENV + echo "git_commit_date=$(git show -s --format=%ci)" >> $GITHUB_ENV + - name: Run Unit Tests + run: | + docker-compose build + docker-compose run inventree-dev-server invoke update + docker-compose up -d + docker-compose run inventree-dev-server invoke wait + docker-compose run inventree-dev-server invoke test + docker-compose down + - name: Set up QEMU + if: github.event_name != 'pull_request' + uses: docker/setup-qemu-action@v1 + - name: Set up Docker Buildx + if: github.event_name != 'pull_request' + uses: docker/setup-buildx-action@v1 + - name: Login to Dockerhub + if: github.event_name != 'pull_request' + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Build and Push + if: github.event_name != 'pull_request' + uses: docker/build-push-action@v2 + with: + context: . + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: false + target: production + tags: inventree/inventree:${{ env.docker_tag }} + build-args: commit_hash=${{ env.git_commit_hash }},commit_date=${{ env.git_commit_date }},commit_tag=${{ env.docker_tag }} diff --git a/.github/workflows/docker_latest.yaml b/.github/workflows/docker_latest.yaml deleted file mode 100644 index 74b5eb966c..0000000000 --- a/.github/workflows/docker_latest.yaml +++ /dev/null @@ -1,51 +0,0 @@ -# Build and push latest docker image on push to master branch - -name: Docker Build - -on: - push: - branches: - - 'master' - -jobs: - - docker: - runs-on: ubuntu-latest - - steps: - - name: Checkout Code - uses: actions/checkout@v2 - - name: Check version number - run: | - python3 ci/check_version_number.py --dev - - name: Build Docker Image - run: | - cd docker - docker-compose build - docker-compose run inventree-dev-server invoke update - - name: Run unit tests - run: | - cd docker - docker-compose up -d - docker-compose run inventree-dev-server invoke wait - docker-compose run inventree-dev-server invoke test - docker-compose down - - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - name: Login to Dockerhub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build and Push - uses: docker/build-push-action@v2 - with: - context: ./docker - platforms: linux/amd64,linux/arm64,linux/arm/v7 - push: true - target: production - tags: inventree/inventree:latest - - name: Image Digest - run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/docker_stable.yaml b/.github/workflows/docker_stable.yaml deleted file mode 100644 index e892b24d13..0000000000 --- a/.github/workflows/docker_stable.yaml +++ /dev/null @@ -1,42 +0,0 @@ -# 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 - -on: - push: - branches: - - 'stable' - -jobs: - - docker: - runs-on: ubuntu-latest - - steps: - - name: Checkout Code - uses: actions/checkout@v2 - - name: Check version number - run: | - python3 ci/check_version_number.py --release - - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - name: Login to Dockerhub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build and Push - uses: docker/build-push-action@v2 - with: - context: ./docker - platforms: linux/amd64,linux/arm64,linux/arm/v7 - push: true - target: production - build-args: - branch=stable - tags: inventree/inventree:stable - - name: Image Digest - run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/docker_tag.yaml b/.github/workflows/docker_tag.yaml deleted file mode 100644 index a9f1c646fc..0000000000 --- a/.github/workflows/docker_tag.yaml +++ /dev/null @@ -1,38 +0,0 @@ -# Publish docker images to dockerhub on a tagged release -# Docker build will be uploaded to dockerhub with the 'invetree:' tag - -name: Docker Publish - -on: - release: - types: [published] - -jobs: - publish_image: - name: Push InvenTree web server image to dockerhub - runs-on: ubuntu-latest - steps: - - name: Check out repo - uses: actions/checkout@v2 - - name: Check Release tag - run: | - python3 ci/check_version_number.py --release --tag ${{ github.event.release.tag_name }} - - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - name: Login to Dockerhub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build and Push - uses: docker/build-push-action@v2 - with: - context: ./docker - platforms: linux/amd64,linux/arm64,linux/arm/v7 - push: true - target: production - build-args: - tag=${{ github.event.release.tag_name }} - tags: inventree/inventree:${{ github.event.release.tag_name }} diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index cf2700c3d3..9bc0484fa0 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -91,6 +91,9 @@ jobs: cache: 'pip' - name: Run pre-commit Checks uses: pre-commit/action@v2.0.3 + - name: Check version number + run: | + python3 ci/check_version_number.py python: name: Tests - inventree-python @@ -114,7 +117,7 @@ jobs: - name: Enviroment Setup uses: ./.github/actions/setup with: - apt-dependency: gettext + apt-dependency: gettext poppler-utils update: true - name: Download Python Code For `${{ env.wrapper_name }}` run: git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }} ./${{ env.wrapper_name }} @@ -147,7 +150,7 @@ jobs: - name: Enviroment Setup uses: ./.github/actions/setup with: - apt-dependency: gettext + apt-dependency: gettext poppler-utils update: true - name: Coverage Tests run: invoke coverage @@ -196,7 +199,7 @@ jobs: - name: Enviroment Setup uses: ./.github/actions/setup with: - apt-dependency: gettext libpq-dev + apt-dependency: gettext poppler-utils libpq-dev pip-dependency: psycopg2 django-redis>=5.0.0 update: true - name: Run Tests @@ -239,7 +242,7 @@ jobs: - name: Enviroment Setup uses: ./.github/actions/setup with: - apt-dependency: gettext libmysqlclient-dev + apt-dependency: gettext poppler-utils libmysqlclient-dev pip-dependency: mysqlclient update: true - name: Run Tests diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml deleted file mode 100644 index 73d5bd8a2c..0000000000 --- a/.github/workflows/version.yml +++ /dev/null @@ -1,21 +0,0 @@ -# Checks version number -name: version number - -on: - pull_request: - branches-ignore: - - l10* - - -jobs: - - check_version: - name: version number - runs-on: ubuntu-latest - - steps: - - name: Checkout Code - uses: actions/checkout@v2 - - name: Check version number - run: | - python3 ci/check_version_number.py --branch ${{ github.base_ref }} diff --git a/.gitignore b/.gitignore index 56d4180482..9c9a45d136 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ env/ inventree-env/ ./build/ +.cache/ develop-eggs/ dist/ bin/ @@ -26,7 +27,6 @@ var/ .installed.cfg *.egg - # Django stuff: *.log local_settings.py @@ -38,6 +38,8 @@ local_settings.py # Files used for testing dummy_image.* _tmp.csv +inventree/label.pdf +inventree/label.png # Sphinx files docs/_build @@ -63,6 +65,7 @@ secret_key.txt .idea/ *.code-workspace .vscode/ +.bash_history # Coverage reports .coverage diff --git a/docker/Dockerfile b/Dockerfile similarity index 53% rename from docker/Dockerfile rename to Dockerfile index 1b7c16db30..361ef686f0 100644 --- a/docker/Dockerfile +++ b/Dockerfile @@ -1,37 +1,39 @@ -FROM alpine:3.14 as base +# The InvenTree dockerfile provides two build targets: +# +# production: +# - Required files are copied into the image +# - Runs InvenTree web server under gunicorn +# +# dev: +# - Expects source directories to be loaded as a run-time volume +# - Runs InvenTree web server under django development server +# - Monitors source files for any changes, and live-reloads server -# GitHub source -ARG repository="https://github.com/inventree/InvenTree.git" -ARG branch="master" -# Optionally specify a particular tag to checkout -ARG tag="" +FROM python:3.9-slim as base + +# Build arguments for this image +ARG commit_hash="" +ARG commit_date="" +ARG commit_tag="" ENV PYTHONUNBUFFERED 1 # Ref: https://github.com/pyca/cryptography/issues/5776 ENV CRYPTOGRAPHY_DONT_BUILD_RUST 1 -# InvenTree key settings - -# The INVENTREE_HOME directory is where the InvenTree source repository will be located -ENV INVENTREE_HOME="/home/inventree" - -# GitHub settings -ENV INVENTREE_GIT_REPO="${repository}" -ENV INVENTREE_GIT_BRANCH="${branch}" -ENV INVENTREE_GIT_TAG="${tag}" - ENV INVENTREE_LOG_LEVEL="INFO" ENV INVENTREE_DOCKER="true" # InvenTree paths +ENV INVENTREE_HOME="/home/inventree" ENV INVENTREE_MNG_DIR="${INVENTREE_HOME}/InvenTree" ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/data" ENV INVENTREE_STATIC_ROOT="${INVENTREE_DATA_DIR}/static" ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media" ENV INVENTREE_PLUGIN_DIR="${INVENTREE_DATA_DIR}/plugins" +# InvenTree configuration files ENV INVENTREE_CONFIG_FILE="${INVENTREE_DATA_DIR}/config.yaml" ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DATA_DIR}/secret_key.txt" ENV INVENTREE_PLUGIN_FILE="${INVENTREE_DATA_DIR}/plugins.txt" @@ -49,82 +51,83 @@ LABEL org.label-schema.schema-version="1.0" \ org.label-schema.vendor="inventree" \ org.label-schema.name="inventree/inventree" \ org.label-schema.url="https://hub.docker.com/r/inventree/inventree" \ - org.label-schema.vcs-url=${INVENTREE_GIT_REPO} \ - org.label-schema.vcs-branch=${INVENTREE_GIT_BRANCH} \ - org.label-schema.vcs-ref=${INVENTREE_GIT_TAG} + org.label-schema.vcs-url="https://github.com/inventree/InvenTree.git" \ + org.label-schema.vcs-ref=${commit_tag} -# Create user account -RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup - -RUN apk -U upgrade +# RUN apt-get upgrade && apt-get update +RUN apt-get update # Install required system packages -RUN apk add --no-cache git make bash \ - gcc libgcc g++ libstdc++ \ - gnupg \ - libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev libwebp-dev \ - libffi libffi-dev \ - zlib zlib-dev \ - # Special deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement) - cairo cairo-dev pango pango-dev gdk-pixbuf \ - # Fonts - fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans font-croscore font-noto \ - # Core python - python3 python3-dev py3-pip \ +RUN apt-get install -y --no-install-recommends \ + git gcc g++ gettext gnupg \ + # Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#debian-11 + poppler-utils libpango-1.0-0 libpangoft2-1.0-0 \ + # Image format support + libjpeg-dev webp \ # SQLite support - sqlite \ + sqlite3 \ # PostgreSQL support - postgresql postgresql-contrib postgresql-dev libpq \ - # MySQL/MariaDB support - mariadb-connector-c mariadb-dev mariadb-client \ - # Required for python cryptography support - openssl-dev musl-dev libffi-dev rust cargo + libpq-dev \ + # MySQL / MariaDB support + default-libmysqlclient-dev mariadb-client && \ + apt-get autoclean && apt-get autoremove # Update pip RUN pip install --upgrade pip # Install required base-level python packages -COPY requirements.txt requirements.txt -RUN pip install --no-cache-dir -U -r requirements.txt +COPY ./docker/requirements.txt base_requirements.txt +RUN pip install --disable-pip-version-check -U -r base_requirements.txt + +# InvenTree production image: +# - Copies required files from local directory +# - Installs required python packages from requirements.txt +# - Starts a gunicorn webserver -# Production code (pulled from tagged github release) FROM base as production -# Clone source code -RUN echo "Downloading InvenTree from ${INVENTREE_GIT_REPO}" +ENV INVENTREE_DEBUG=False -RUN git clone --branch ${INVENTREE_GIT_BRANCH} --depth 1 ${INVENTREE_GIT_REPO} ${INVENTREE_HOME} +# As .git directory is not available in production image, we pass the commit information via ENV +ENV INVENTREE_COMMIT_HASH="${commit_hash}" +ENV INVENTREE_COMMIT_DATE="${commit_date}" -# Ref: https://github.blog/2022-04-12-git-security-vulnerability-announced/ -RUN git config --global --add safe.directory ${INVENTREE_HOME} +# Copy source code +COPY InvenTree ${INVENTREE_HOME}/InvenTree -# Checkout against a particular git tag -RUN if [ -n "${INVENTREE_GIT_TAG}" ] ; then cd ${INVENTREE_HOME} && git fetch --all --tags && git checkout tags/${INVENTREE_GIT_TAG} -b v${INVENTREE_GIT_TAG}-branch ; fi - -RUN chown -R inventree:inventreegroup ${INVENTREE_HOME}/* - -# Drop to the inventree user -USER inventree - -# Install InvenTree packages -RUN pip3 install --user --no-cache-dir --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt +# Copy other key files +COPY requirements.txt ${INVENTREE_HOME}/requirements.txt +COPY tasks.py ${INVENTREE_HOME}/tasks.py +COPY docker/gunicorn.conf.py ${INVENTREE_HOME}/gunicorn.conf.py +COPY docker/init.sh ${INVENTREE_MNG_DIR}/init.sh # Need to be running from within this directory WORKDIR ${INVENTREE_MNG_DIR} +# Drop to the inventree user for the production image +RUN adduser inventree +RUN chown -R inventree:inventree ${INVENTREE_HOME} + +USER inventree + +# Install InvenTree packages +RUN pip3 install --user --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt + # Server init entrypoint -ENTRYPOINT ["/bin/bash", "../docker/init.sh"] +ENTRYPOINT ["/bin/bash", "./init.sh"] # Launch the production server # TODO: Work out why environment variables cannot be interpolated in this command # TODO: e.g. -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} fails here -CMD gunicorn -c ./docker/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree +CMD gunicorn -c ./gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree FROM base as dev # The development image requires the source code to be mounted to /home/inventree/ # So from here, we don't actually "do" anything, apart from some file management +ENV INVENTREE_DEBUG=True + ENV INVENTREE_DEV_DIR="${INVENTREE_HOME}/dev" # Location for python virtual environment diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index 935252de5b..5385f8f01b 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -117,6 +117,11 @@ class InvenTreeAPITestCase(UserMixin, APITestCase): response = self.client.get(url, data, format='json') if expected_code is not None: + + if response.status_code != expected_code: + print(f"Unexpected response at '{url}':") + print(response.data) + self.assertEqual(response.status_code, expected_code) return response diff --git a/InvenTree/InvenTree/exceptions.py b/InvenTree/InvenTree/exceptions.py index 55017affc0..a4737bac4d 100644 --- a/InvenTree/InvenTree/exceptions.py +++ b/InvenTree/InvenTree/exceptions.py @@ -40,7 +40,11 @@ def exception_handler(exc, context): if response is None: # DRF handler did not provide a default response for this exception - if settings.DEBUG: + if settings.TESTING: + # If in TESTING mode, re-throw the exception for traceback + raise exc + elif settings.DEBUG: + # If in DEBUG mode, provide error information in the response error_detail = str(exc) else: error_detail = _("Error details can be found in the admin panel") diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index c541ce4ef5..00ac33ae68 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -129,7 +129,7 @@ def TestIfImageURL(url): Simply tests the extension against a set of allowed values """ return os.path.splitext(os.path.basename(url))[-1].lower() in [ - '.jpg', '.jpeg', + '.jpg', '.jpeg', '.j2k', '.png', '.bmp', '.tif', '.tiff', '.webp', '.gif', diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 61130ad1b4..3de293ca66 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -380,6 +380,30 @@ class TestVersionNumber(TestCase): self.assertTrue(v_d > v_c) self.assertTrue(v_d > v_a) + def test_commit_info(self): + """Test that the git commit information is extracted successfully""" + + envs = { + 'INVENTREE_COMMIT_HASH': 'abcdef', + 'INVENTREE_COMMIT_DATE': '2022-12-31' + } + + # Check that the environment variables take priority + + with mock.patch.dict(os.environ, envs): + self.assertEqual(version.inventreeCommitHash(), 'abcdef') + self.assertEqual(version.inventreeCommitDate(), '2022-12-31') + + import subprocess + + # Check that the current .git values work too + + hash = str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip() + self.assertEqual(hash, version.inventreeCommitHash()) + + d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip().split(' ')[0] + self.assertEqual(d, version.inventreeCommitDate()) + class CurrencyTests(TestCase): """ diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index f1190ec7ba..fe970ee5d4 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -3,6 +3,7 @@ Version information for InvenTree. Provides information on the current InvenTree version """ +import os import re import subprocess @@ -99,6 +100,12 @@ def inventreeDjangoVersion(): def inventreeCommitHash(): """ Returns the git commit hash for the running codebase """ + # First look in the environment variables, i.e. if running in docker + commit_hash = os.environ.get('INVENTREE_COMMIT_HASH', '') + + if commit_hash: + return commit_hash + try: return str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip() except: # pragma: no cover @@ -108,6 +115,12 @@ def inventreeCommitHash(): def inventreeCommitDate(): """ Returns the git commit date for the running codebase """ + # First look in the environment variables, e.g. if running in docker + commit_date = os.environ.get('INVENTREE_COMMIT_DATE', '') + + if commit_date: + return commit_date.split(' ')[0] + try: d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip() return d.split(' ')[0] diff --git a/InvenTree/common/notifications.py b/InvenTree/common/notifications.py index c2e4f2d6aa..aa39ad20ef 100644 --- a/InvenTree/common/notifications.py +++ b/InvenTree/common/notifications.py @@ -203,7 +203,7 @@ class UIMessageNotification(SingleNotificationMethod): return True -def trigger_notifaction(obj, category=None, obj_ref='pk', **kwargs): +def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): """ Send out a notification """ diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index cb7ea12598..7988bfb32f 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -1,12 +1,9 @@ -from io import BytesIO - from django.conf import settings from django.core.exceptions import FieldError, ValidationError from django.http import HttpResponse, JsonResponse from django.urls import include, re_path from django_filters.rest_framework import DjangoFilterBackend -from PIL import Image from rest_framework import filters, generics from rest_framework.exceptions import NotFound @@ -137,25 +134,21 @@ class LabelPrintMixin: # Label instance label_instance = self.get_object() - for output in outputs: + for idx, output in enumerate(outputs): """ For each output, we generate a temporary image file, which will then get sent to the printer """ - # Generate a png image at 300dpi - (img_data, w, h) = output.get_document().write_png(resolution=300) - - # Construct a BytesIO object, which can be read by pillow - img_bytes = BytesIO(img_data) - - image = Image.open(img_bytes) + # Generate PDF data for the label + pdf = output.get_document().write_pdf() # Offload a background task to print the provided label offload_task( plugin_label.print_label, plugin.plugin_slug(), - image, + pdf, + filename=label_names[idx], label_instance=label_instance, user=request.user, ) diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py index 18188bed77..9037fb92be 100644 --- a/InvenTree/part/tasks.py +++ b/InvenTree/part/tasks.py @@ -24,7 +24,7 @@ def notify_low_stock(part: part.models.Part): }, } - common.notifications.trigger_notifaction( + common.notifications.trigger_notification( part, 'part.notify_low_stock', target_fnc=part.get_subscribers, diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 3819a01706..02fbe5d29e 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -1098,7 +1098,7 @@ class PartDetailTests(InvenTreeAPITestCase): self.assertIn('Upload a valid image', str(response.data)) # Now try to upload a valid image file, in multiple formats - for fmt in ['jpg', 'png', 'bmp', 'webp']: + for fmt in ['jpg', 'j2k', 'png', 'bmp', 'webp']: fn = f'dummy_image.{fmt}' img = PIL.Image.new('RGB', (128, 128), color='red') diff --git a/InvenTree/plugin/base/label/label.py b/InvenTree/plugin/base/label/label.py index d85251dd81..56eaf1bc20 100644 --- a/InvenTree/plugin/base/label/label.py +++ b/InvenTree/plugin/base/label/label.py @@ -1,7 +1,14 @@ """Functions to print a label to a mixin printer""" import logging +import sys +import traceback +from django.conf import settings from django.utils.translation import gettext_lazy as _ +from django.views.debug import ExceptionReporter + +import pdf2image +from error_report.models import Error import common.notifications from plugin.registry import registry @@ -9,7 +16,7 @@ from plugin.registry import registry logger = logging.getLogger('inventree') -def print_label(plugin_slug, label_image, label_instance=None, user=None): +def print_label(plugin_slug, pdf_data, filename=None, label_instance=None, user=None): """ Print label with the provided plugin. @@ -19,10 +26,11 @@ def print_label(plugin_slug, label_image, label_instance=None, user=None): Arguments: plugin_slug: The unique slug (key) of the plugin - label_image: A PIL.Image image object to be printed + pdf_data: Binary PDF data + filename: The intended name of the printed label """ - logger.info(f"Plugin '{plugin_slug}' is printing a label") + logger.info(f"Plugin '{plugin_slug}' is printing a label '{filename}'") plugin = registry.plugins.get(plugin_slug, None) @@ -30,8 +38,22 @@ def print_label(plugin_slug, label_image, label_instance=None, user=None): logger.error(f"Could not find matching plugin for '{plugin_slug}'") return + # In addition to providing a .pdf image, we'll also provide a .png file + png_file = pdf2image.convert_from_bytes( + pdf_data, + dpi=300, + )[0] + try: - plugin.print_label(label_image, width=label_instance.width, height=label_instance.height) + plugin.print_label( + pdf_data=pdf_data, + png_file=png_file, + filename=filename, + label_instance=label_instance, + width=label_instance.width, + height=label_instance.height, + user=user + ) except Exception as e: # pragma: no cover # Plugin threw an error - notify the user who attempted to print @@ -40,13 +62,28 @@ def print_label(plugin_slug, label_image, label_instance=None, user=None): 'message': str(e), } - logger.error(f"Label printing failed: Sending notification to user '{user}'") + # Log an error message to the database + kind, info, data = sys.exc_info() + + Error.objects.create( + kind=kind.__name__, + info=info, + data='\n'.join(traceback.format_exception(kind, info, data)), + path='print_label', + html=ExceptionReporter(None, kind, info, data).get_traceback_html(), + ) + + logger.error(f"Label printing failed: Sending notification to user '{user}'") # pragma: no cover # Throw an error against the plugin instance - common.notifications.trigger_notifaction( + common.notifications.trigger_notification( plugin.plugin_config(), 'label.printing_failed', targets=[user], context=ctx, - delivery_methods=[common.notifications.UIMessageNotification] + delivery_methods=set([common.notifications.UIMessageNotification]) ) + + if settings.TESTING: + # If we are in testing mode, we want to know about this exception + raise e diff --git a/InvenTree/plugin/base/label/mixins.py b/InvenTree/plugin/base/label/mixins.py index 4e06f9e15a..aa17b1812a 100644 --- a/InvenTree/plugin/base/label/mixins.py +++ b/InvenTree/plugin/base/label/mixins.py @@ -22,17 +22,18 @@ class LabelPrintingMixin: super().__init__() self.add_mixin('labels', True, __class__) - def print_label(self, label, **kwargs): + def print_label(self, **kwargs): """ Callback to print a single label - Arguments: - label: A black-and-white pillow Image object - kwargs: - length: The length of the label (in mm) - width: The width of the label (in mm) - + pdf_data: Raw PDF data of the rendered label + png_file: An in-memory PIL image file, rendered at 300dpi + label_instance: The instance of the label model which triggered the print_label() method + width: The expected width of the label (in mm) + height: The expected height of the label (in mm) + filename: The filename of this PDF label + user: The user who printed this label """ # Unimplemented (to be implemented by the particular plugin class) diff --git a/InvenTree/plugin/base/label/test_label_mixin.py b/InvenTree/plugin/base/label/test_label_mixin.py index 29250f76a5..53294d2f24 100644 --- a/InvenTree/plugin/base/label/test_label_mixin.py +++ b/InvenTree/plugin/base/label/test_label_mixin.py @@ -1,8 +1,11 @@ """Unit tests for the label printing mixin""" +import os from django.apps import apps from django.urls import reverse +from PIL import Image + from common.models import InvenTreeSetting from InvenTree.api_tester import InvenTreeAPITestCase from label.models import PartLabel, StockItemLabel, StockLocationLabel @@ -68,7 +71,7 @@ class LabelMixinTests(InvenTreeAPITestCase): with self.assertRaises(MixinNotImplementedError): plugin = WrongPlugin() - plugin.print_label('test') + plugin.print_label(filename='test') def test_installed(self): """Test that the sample printing plugin is installed""" @@ -167,6 +170,21 @@ class LabelMixinTests(InvenTreeAPITestCase): # Print no part self.get(self.do_url(None, plugin_ref, label), expected_code=400) + # Test that the labels have been printed + # The sample labelling plugin simply prints to file + self.assertTrue(os.path.exists('label.pdf')) + + # Read the raw .pdf data - ensure it contains some sensible information + with open('label.pdf', 'rb') as f: + pdf_data = str(f.read()) + self.assertIn('WeasyPrint', pdf_data) + + # Check that the .png file has already been created + self.assertTrue(os.path.exists('label.png')) + + # And that it is a valid image file + Image.open('label.png') + def test_printing_endpoints(self): """Cover the endpoints not covered by `test_printing_process`""" plugin_ref = 'samplelabel' diff --git a/InvenTree/plugin/samples/integration/label_sample.py b/InvenTree/plugin/samples/integration/label_sample.py index 845e1b7908..c01c575012 100644 --- a/InvenTree/plugin/samples/integration/label_sample.py +++ b/InvenTree/plugin/samples/integration/label_sample.py @@ -12,7 +12,22 @@ class SampleLabelPrinter(LabelPrintingMixin, InvenTreePlugin): SLUG = "samplelabel" TITLE = "Sample Label Printer" DESCRIPTION = "A sample plugin which provides a (fake) label printer interface" - VERSION = "0.1" + VERSION = "0.2" - def print_label(self, label, **kwargs): - print("OK PRINTING") + def print_label(self, **kwargs): + + # Test that the expected kwargs are present + print(f"Printing Label: {kwargs['filename']} (User: {kwargs['user']})") + print(f"Width: {kwargs['width']} x Height: {kwargs['height']}") + + pdf_data = kwargs['pdf_data'] + png_file = kwargs['png_file'] + + filename = kwargs['filename'] + + # Dump the PDF to a local file + with open(filename, 'wb') as pdf_out: + pdf_out.write(pdf_data) + + # Save the PNG to disk + png_file.save(filename.replace('.pdf', '.png')) diff --git a/ci/check_version_number.py b/ci/check_version_number.py index 3845cdfe27..7a3afdf5a0 100644 --- a/ci/check_version_number.py +++ b/ci/check_version_number.py @@ -1,8 +1,19 @@ """ -On release, ensure that the release tag matches the InvenTree version number! +Ensure that the release tag matches the InvenTree version number: + +master / main branch: + - version number must end with 'dev' + +stable branch: + - version number must *not* end with 'dev' + - version number cannot already exist as a release tag + +tagged branch: + - version number must match tag being built + - version number cannot already exist as a release tag + """ -import argparse import os import re import sys @@ -11,6 +22,15 @@ if __name__ == '__main__': here = os.path.abspath(os.path.dirname(__file__)) + # GITHUB_REF_TYPE may be either 'branch' or 'tag' + GITHUB_REF_TYPE = os.environ['GITHUB_REF_TYPE'] + + # GITHUB_REF may be either 'refs/heads/' or 'refs/heads/' + GITHUB_REF = os.environ['GITHUB_REF'] + + # GITHUB_BASE_REF is the base branch e.g. 'master' or 'stable' + GITHUB_BASE_REF = os.environ['GITHUB_BASE_REF'] + version_file = os.path.join(here, '..', 'InvenTree', 'InvenTree', 'version.py') version = None @@ -30,66 +50,65 @@ if __name__ == '__main__': print(f"InvenTree Version: '{version}'") - parser = argparse.ArgumentParser() - parser.add_argument('-t', '--tag', help='Compare against specified version tag', action='store') - parser.add_argument('-r', '--release', help='Check that this is a release version', action='store_true') - parser.add_argument('-d', '--dev', help='Check that this is a development version', action='store_true') - parser.add_argument('-b', '--branch', help='Check against a particular branch', action='store') + # Determine which docker tag we are going to use + docker_tag = None - args = parser.parse_args() - - if args.branch: - """ - Version number requirement depends on format of branch - - 'master': development branch - 'stable': release branch - """ - - print(f"Checking version number for branch '{args.branch}'") - - if args.branch == 'master': - print("- This is a development branch") - args.dev = True - elif args.branch == 'stable': - print("- This is a stable release branch") - args.release = True - - if args.dev: - """ - Check that the current verrsion number matches the "development" format - e.g. "0.5 dev" - """ - - print("Checking development branch") - - pattern = r"^\d+(\.\d+)+ dev$" - - result = re.match(pattern, version) - - if result is None: - print(f"Version number '{version}' does not match required pattern for development branch") - sys.exit(1) - - elif args.release: - """ - Check that the current version number matches the "release" format - e.g. "0.5.1" - """ - - print("Checking release branch") + if GITHUB_BASE_REF == 'stable' and GITHUB_REF_TYPE == 'branch': + print("Checking requirements for 'stable' release") pattern = r"^\d+(\.\d+)+$" - result = re.match(pattern, version) if result is None: print(f"Version number '{version}' does not match required pattern for stable branch") sys.exit(1) + else: + print(f"Version number '{version}' matches stable branch") - if args.tag: - if args.tag != version: - print(f"Release tag '{args.tag}' does not match INVENTREE_SW_VERSION '{version}'") + docker_tag = 'stable' + + elif GITHUB_BASE_REF in ['master', 'main'] and GITHUB_REF_TYPE == 'branch': + print("Checking requirements for main development branch:") + + pattern = r"^\d+(\.\d+)+ dev$" + result = re.match(pattern, version) + + if result is None: + print(f"Version number '{version}' does not match required pattern for development branch") sys.exit(1) + else: + print(f"Version number '{version}' matches development branch") -sys.exit(0) + docker_tag = 'latest' + + elif GITHUB_REF_TYPE == 'tag': + # GITHUB_REF should be of th eform /refs/heads/ + version_tag = GITHUB_REF.split('/')[-1] + print(f"Checking requirements for tagged release - '{version_tag}'") + + if version_tag != version: + print(f"Version number '{version}' does not match tag '{version_tag}'") + sys.exit + + # TODO: Check if there is already a release with this tag! + + docker_tag = version_tag + + else: + print("Unsupported branch / version combination:") + print(f"InvenTree Version: {version}") + print("GITHUB_REF_TYPE:", GITHUB_REF_TYPE) + print("GITHUB_REF:", GITHUB_REF) + print("GITHUB_BASE_REF:", GITHUB_BASE_REF) + sys.exit(1) + + if docker_tag is None: + print("Docker tag could not be determined") + sys.exit(1) + + print(f"Version check passed for '{version}'!") + print(f"Docker tag: '{docker_tag}'") + + # Ref: https://getridbug.com/python/how-to-set-environment-variables-in-github-actions-using-python/ + with open(os.getenv('GITHUB_ENV'), 'a') as env_file: + env_file.write(f"docker_tag={docker_tag}\n") diff --git a/docker/docker-compose.yml b/docker-compose.yml similarity index 98% rename from docker/docker-compose.yml rename to docker-compose.yml index e8bb12c44a..baba646883 100644 --- a/docker/docker-compose.yml +++ b/docker-compose.yml @@ -101,4 +101,4 @@ volumes: o: bind # This directory specified where InvenTree source code is stored "outside" the docker containers # By default, this directory is one level above the "docker" directory - device: ${INVENTREE_EXT_VOLUME:-../} + device: ${INVENTREE_EXT_VOLUME:-./} diff --git a/docker/init.sh b/docker/init.sh index 088dd68e89..47f05afeb0 100644 --- a/docker/init.sh +++ b/docker/init.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash # exit when any command fails set -e diff --git a/docker/production/.env b/docker/production/.env index 220952bf23..9bf801dba5 100644 --- a/docker/production/.env +++ b/docker/production/.env @@ -16,6 +16,12 @@ INVENTREE_WEB_PORT=1337 INVENTREE_DEBUG=False INVENTREE_LOG_LEVEL=WARNING +# InvenTree admin account details +# Un-comment (and complete) these lines to auto-create an admin acount +#INVENTREE_ADMIN_USER= +#INVENTREE_ADMIN_PASSWORD= +#INVENTREE_ADMIN_EMAIL= + # Database configuration options # Note: The example setup is for a PostgreSQL database INVENTREE_DB_ENGINE=postgresql diff --git a/requirements.txt b/requirements.txt index 822d40fc54..9b857e72ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,16 +29,16 @@ django-sslserver==0.22 # Secure HTTP development server django-stdimage==5.1.1 # Advanced ImageField management django-test-migrations==1.1.0 # Unit testing for database migrations django-user-sessions==1.7.1 # user sessions in DB -django-weasyprint==1.0.1 # django weasyprint integration +django-weasyprint==2.1.0 # django weasyprint integration djangorestframework==3.12.4 # DRF framework django-xforwardedfor-middleware==2.0 # IP forwarding metadata flake8==3.8.3 # PEP checking flake8-docstrings==1.6.0 # docstring format testing gunicorn>=20.1.0 # Gunicorn web server importlib_metadata # Backport for importlib.metadata -inventree # Install the latest version of the InvenTree API python library isort==5.10.1 # DEV: python import sorting markdown==3.3.4 # Force particular version of markdown +pdf2image==1.16.0 # PDF to image conversion pep8-naming==0.11.1 # PEP naming convention extension pre-commit==2.19.0 # Git pre-commit pillow==9.1.0 # Image manipulation @@ -48,4 +48,4 @@ python-barcode[images]==0.13.1 # Barcode generator qrcode[pil]==6.1 # QR code generator rapidfuzz==0.7.6 # Fuzzy string matching tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats -weasyprint==52.5 # PDF generation library (Note: in the future need to update to 53) +weasyprint==55.0 # PDF generation library diff --git a/tasks.py b/tasks.py index 9fa55c4513..4ecebfc8e8 100644 --- a/tasks.py +++ b/tasks.py @@ -82,7 +82,7 @@ def plugins(c): print(f"Installing plugin packages from '{plugin_file}'") # Install the plugins - c.run(f"pip3 install -U -r '{plugin_file}'") + c.run(f"pip3 install --disable-pip-version-check -U -r '{plugin_file}'") @task(post=[plugins]) @@ -94,7 +94,7 @@ def install(c): print("Installing required python packages from 'requirements.txt'") # Install required Python packages with PIP - c.run('pip3 install -U -r requirements.txt') + c.run('pip3 install --no-cache-dir --disable-pip-version-check -U -r requirements.txt') @task From ea465497c0f3e2870469921d2bfe668d5fd1b702 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 29 May 2022 09:49:32 +1000 Subject: [PATCH 14/16] Fixes for version check script (#3091) - GITHUB_BASE_REF not available on a push! --- ci/check_version_number.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/ci/check_version_number.py b/ci/check_version_number.py index 7a3afdf5a0..da86d4a5ad 100644 --- a/ci/check_version_number.py +++ b/ci/check_version_number.py @@ -28,9 +28,6 @@ if __name__ == '__main__': # GITHUB_REF may be either 'refs/heads/' or 'refs/heads/' GITHUB_REF = os.environ['GITHUB_REF'] - # GITHUB_BASE_REF is the base branch e.g. 'master' or 'stable' - GITHUB_BASE_REF = os.environ['GITHUB_BASE_REF'] - version_file = os.path.join(here, '..', 'InvenTree', 'InvenTree', 'version.py') version = None @@ -53,7 +50,7 @@ if __name__ == '__main__': # Determine which docker tag we are going to use docker_tag = None - if GITHUB_BASE_REF == 'stable' and GITHUB_REF_TYPE == 'branch': + if GITHUB_REF_TYPE == 'branch' and 'stable' in GITHUB_REF: print("Checking requirements for 'stable' release") pattern = r"^\d+(\.\d+)+$" @@ -67,7 +64,7 @@ if __name__ == '__main__': docker_tag = 'stable' - elif GITHUB_BASE_REF in ['master', 'main'] and GITHUB_REF_TYPE == 'branch': + elif GITHUB_REF_TYPE == 'branch' and ('master' in GITHUB_REF or 'main' in GITHUB_REF): print("Checking requirements for main development branch:") pattern = r"^\d+(\.\d+)+ dev$" @@ -99,7 +96,6 @@ if __name__ == '__main__': print(f"InvenTree Version: {version}") print("GITHUB_REF_TYPE:", GITHUB_REF_TYPE) print("GITHUB_REF:", GITHUB_REF) - print("GITHUB_BASE_REF:", GITHUB_BASE_REF) sys.exit(1) if docker_tag is None: From 1f6b81745dd6410f9d1ffd90838e54e6191f4ea5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 29 May 2022 11:07:09 +1000 Subject: [PATCH 15/16] Docker: Require libffi-dev (#3092) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 361ef686f0..cec2424621 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,7 +59,7 @@ RUN apt-get update # Install required system packages RUN apt-get install -y --no-install-recommends \ - git gcc g++ gettext gnupg \ + git gcc g++ gettext gnupg libffi-dev \ # Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#debian-11 poppler-utils libpango-1.0-0 libpangoft2-1.0-0 \ # Image format support From deacf207ffd65250890b494fc2b11ed14b3000d6 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 29 May 2022 13:47:29 +1000 Subject: [PATCH 16/16] Docker Build Fixes (#3093) * Docker: Require libffi-dev * set push to true * debug * Check GITHUB_BASE_REF also --- .github/workflows/docker.yaml | 2 +- ci/check_version_number.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index b9f895983d..bc835efb98 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -63,7 +63,7 @@ jobs: with: context: . platforms: linux/amd64,linux/arm64,linux/arm/v7 - push: false + push: true target: production tags: inventree/inventree:${{ env.docker_tag }} build-args: commit_hash=${{ env.git_commit_hash }},commit_date=${{ env.git_commit_date }},commit_tag=${{ env.docker_tag }} diff --git a/ci/check_version_number.py b/ci/check_version_number.py index da86d4a5ad..27120ff55a 100644 --- a/ci/check_version_number.py +++ b/ci/check_version_number.py @@ -28,6 +28,8 @@ if __name__ == '__main__': # GITHUB_REF may be either 'refs/heads/' or 'refs/heads/' GITHUB_REF = os.environ['GITHUB_REF'] + GITHUB_BASE_REF = os.environ['GITHUB_BASE_REF'] + version_file = os.path.join(here, '..', 'InvenTree', 'InvenTree', 'version.py') version = None @@ -50,7 +52,7 @@ if __name__ == '__main__': # Determine which docker tag we are going to use docker_tag = None - if GITHUB_REF_TYPE == 'branch' and 'stable' in GITHUB_REF: + if GITHUB_REF_TYPE == 'branch' and ('stable' in GITHUB_REF or 'stable' in GITHUB_BASE_REF): print("Checking requirements for 'stable' release") pattern = r"^\d+(\.\d+)+$" @@ -64,7 +66,7 @@ if __name__ == '__main__': docker_tag = 'stable' - elif GITHUB_REF_TYPE == 'branch' and ('master' in GITHUB_REF or 'main' in GITHUB_REF): + elif GITHUB_REF_TYPE == 'branch' and ('master' in GITHUB_REF or 'master' in GITHUB_BASE_REF): print("Checking requirements for main development branch:") pattern = r"^\d+(\.\d+)+ dev$" @@ -95,6 +97,7 @@ if __name__ == '__main__': print("Unsupported branch / version combination:") print(f"InvenTree Version: {version}") print("GITHUB_REF_TYPE:", GITHUB_REF_TYPE) + print("GITHUB_BASE_REF:", GITHUB_BASE_REF) print("GITHUB_REF:", GITHUB_REF) sys.exit(1)