diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py
index 7c8f71ea9a..09ec02f158 100644
--- a/InvenTree/InvenTree/api.py
+++ b/InvenTree/InvenTree/api.py
@@ -6,6 +6,7 @@ Main JSON interface views
from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
+from django.conf import settings
from django.http import JsonResponse
from django_filters.rest_framework import DjangoFilterBackend
@@ -37,6 +38,7 @@ class InfoView(AjaxView):
'instance': inventreeInstanceName(),
'apiVersion': inventreeApiVersion(),
'worker_running': is_worker_running(),
+ 'plugins_enabled': settings.PLUGINS_ENABLED,
}
return JsonResponse(data)
diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py
index 6d4848f436..bb36b28ff8 100644
--- a/InvenTree/InvenTree/version.py
+++ b/InvenTree/InvenTree/version.py
@@ -12,11 +12,14 @@ import common.models
INVENTREE_SW_VERSION = "0.7.0 dev"
# InvenTree API version
-INVENTREE_API_VERSION = 32
+INVENTREE_API_VERSION = 33
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
+v33 -> 2022-03-24
+ - Adds "plugins_enabled" information to root API endpoint
+
v32 -> 2022-03-19
- Adds "parameters" detail to Part API endpoint (use ¶meters=true)
- Adds ability to filter PartParameterTemplate API by Part instance
diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html
index 21bddfd6ca..0ed3c01f7e 100644
--- a/InvenTree/build/templates/build/detail.html
+++ b/InvenTree/build/templates/build/detail.html
@@ -258,6 +258,19 @@
+
+
+
+
+
+
+
+
+
{% include "filter_list.html" with id='incompletebuilditems' %}
{% endif %}
@@ -468,6 +481,23 @@ inventreeGet(
)
});
+ $('#incomplete-output-print-label').click(function() {
+ var outputs = $('#build-output-table').bootstrapTable('getSelections');
+
+ if (outputs.length == 0) {
+ outputs = $('#build-output-table').bootstrapTable('getData');
+ }
+
+ var stock_id_values = [];
+
+ outputs.forEach(function(output) {
+ stock_id_values.push(output.pk);
+ });
+
+ printStockItemLabels(stock_id_values);
+
+ });
+
{% endif %}
{% if build.active and build.has_untracked_bom_items %}
diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py
index 475d4f8fea..1a679d5ac5 100644
--- a/InvenTree/label/api.py
+++ b/InvenTree/label/api.py
@@ -1,10 +1,15 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
+from io import BytesIO
+from PIL import Image
+
from django.utils.translation import ugettext_lazy as _
+
+from django.conf import settings
from django.conf.urls import url, include
from django.core.exceptions import ValidationError, FieldError
-from django.http import HttpResponse
+from django.http import HttpResponse, JsonResponse
from django_filters.rest_framework import DjangoFilterBackend
@@ -12,8 +17,11 @@ from rest_framework import generics, filters
from rest_framework.response import Response
import InvenTree.helpers
+from InvenTree.tasks import offload_task
import common.models
+from plugin.registry import registry
+
from stock.models import StockItem, StockLocation
from part.models import Part
@@ -46,11 +54,43 @@ class LabelPrintMixin:
Mixin for printing labels
"""
+ def get_plugin(self, request):
+ """
+ Return the label printing plugin associated with this request.
+ This is provided in the url, e.g. ?plugin=myprinter
+
+ Requires:
+ - settings.PLUGINS_ENABLED is True
+ - matching plugin can be found
+ - matching plugin implements the 'labels' mixin
+ - matching plugin is enabled
+ """
+
+ if not settings.PLUGINS_ENABLED:
+ return None
+
+ plugin_key = request.query_params.get('plugin', None)
+
+ for slug, plugin in registry.plugins.items():
+
+ if slug == plugin_key and plugin.mixin_enabled('labels'):
+
+ config = plugin.plugin_config()
+
+ if config and config.active:
+ # Only return the plugin if it is enabled!
+ return plugin
+
+ # No matches found
+ return None
+
def print(self, request, items_to_print):
"""
Print this label template against a number of pre-validated items
"""
+ # Check the request to determine if the user has selected a label printing plugin
+ plugin = self.get_plugin(request)
if len(items_to_print) == 0:
# No valid items provided, return an error message
data = {
@@ -66,6 +106,8 @@ class LabelPrintMixin:
label_name = "label.pdf"
+ label_names = []
+
# Merge one or more PDF files into a single download
for item in items_to_print:
label = self.get_object()
@@ -73,6 +115,8 @@ class LabelPrintMixin:
label_name = label.generate_filename(request)
+ label_names.append(label_name)
+
if debug_mode:
outputs.append(label.render_as_string(request))
else:
@@ -81,7 +125,46 @@ class LabelPrintMixin:
if not label_name.endswith(".pdf"):
label_name += ".pdf"
- if debug_mode:
+ if plugin is not None:
+ """
+ Label printing is to be handled by a plugin,
+ rather than being exported to PDF.
+
+ In this case, we do the following:
+
+ - Individually generate each label, exporting as an image file
+ - Pass all the images through to the label printing plugin
+ - Return a JSON response indicating that the printing has been offloaded
+
+ """
+
+ for output in 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)
+
+ # Offload a background task to print the provided label
+ offload_task(
+ 'plugin.events.print_label',
+ plugin.plugin_slug(),
+ image
+ )
+
+ return JsonResponse({
+ 'plugin': plugin.plugin_slug(),
+ 'labels': label_names,
+ })
+
+ elif debug_mode:
"""
Contatenate all rendered templates into a single HTML string,
and return the string as a HTML response.
@@ -90,6 +173,7 @@ class LabelPrintMixin:
html = "\n".join(outputs)
return HttpResponse(html)
+
else:
"""
Concatenate all rendered pages into a single PDF object,
diff --git a/InvenTree/label/templates/label/label_base.html b/InvenTree/label/templates/label/label_base.html
index 2986c8a439..363a7d3144 100644
--- a/InvenTree/label/templates/label/label_base.html
+++ b/InvenTree/label/templates/label/label_base.html
@@ -13,6 +13,8 @@
body {
font-family: Arial, Helvetica, sans-serif;
margin: 0mm;
+ color: #000;
+ background-color: #FFF;
}
img {
diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py
index 3deab1ecd1..dc93e00efa 100644
--- a/InvenTree/part/templatetags/inventree_extras.py
+++ b/InvenTree/part/templatetags/inventree_extras.py
@@ -8,6 +8,7 @@ over and above the built-in Django tags.
from datetime import date, datetime
import os
import sys
+import logging
from django.utils.html import format_html
@@ -31,6 +32,9 @@ from plugin.models import PluginSetting
register = template.Library()
+logger = logging.getLogger('inventree')
+
+
@register.simple_tag()
def define(value, *args, **kwargs):
"""
@@ -57,8 +61,19 @@ def render_date(context, date_object):
return None
if type(date_object) == str:
+
+ date_object = date_object.strip()
+
+ # Check for empty string
+ if len(date_object) == 0:
+ return None
+
# If a string is passed, first convert it to a datetime
- date_object = date.fromisoformat(date_object)
+ try:
+ date_object = date.fromisoformat(date_object)
+ except ValueError:
+ logger.warning(f"Tried to convert invalid date string: {date_object}")
+ return None
# We may have already pre-cached the date format by calling this already!
user_date_format = context.get('user_date_format', None)
diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py
index 1e6b7e38b7..451ddaf40f 100644
--- a/InvenTree/plugin/builtin/integration/mixins.py
+++ b/InvenTree/plugin/builtin/integration/mixins.py
@@ -393,6 +393,38 @@ class AppMixin:
return True
+class LabelPrintingMixin:
+ """
+ Mixin which enables direct printing of stock labels.
+
+ Each plugin must provide a PLUGIN_NAME attribute, which is used to uniquely identify the printer.
+
+ The plugin must also implement the print_label() function
+ """
+
+ class MixinMeta:
+ """
+ Meta options for this mixin
+ """
+ MIXIN_NAME = 'Label printing'
+
+ def __init__(self):
+ super().__init__()
+ self.add_mixin('labels', True, __class__)
+
+ def print_label(self, label, **kwargs):
+ """
+ Callback to print a single label
+
+ Arguments:
+ label: A black-and-white pillow Image object
+
+ """
+
+ # Unimplemented (to be implemented by the particular plugin class)
+ ...
+
+
class APICallMixin:
"""
Mixin that enables easier API calls for a plugin
diff --git a/InvenTree/plugin/events.py b/InvenTree/plugin/events.py
index 049c8626c5..829aeaa935 100644
--- a/InvenTree/plugin/events.py
+++ b/InvenTree/plugin/events.py
@@ -95,7 +95,11 @@ def process_event(plugin_slug, event, *args, **kwargs):
logger.info(f"Plugin '{plugin_slug}' is processing triggered event '{event}'")
- plugin = registry.plugins[plugin_slug]
+ plugin = registry.plugins.get(plugin_slug, None)
+
+ if plugin is None:
+ logger.error(f"Could not find matching plugin for '{plugin_slug}'")
+ return
plugin.process_event(event, *args, **kwargs)
@@ -186,3 +190,25 @@ def after_delete(sender, instance, **kwargs):
model=sender.__name__,
table=table,
)
+
+
+def print_label(plugin_slug, label_image, **kwargs):
+ """
+ Print label with the provided plugin.
+
+ This task is nominally handled by the background worker.
+
+ Arguments:
+ plugin_slug: The unique slug (key) of the plugin
+ label_image: A PIL.Image image object to be printed
+ """
+
+ logger.info(f"Plugin '{plugin_slug}' is printing a label")
+
+ plugin = registry.plugins.get(plugin_slug, None)
+
+ if plugin is None:
+ logger.error(f"Could not find matching plugin for '{plugin_slug}'")
+ return
+
+ plugin.print_label(label_image)
diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py
index 8097b0b459..86e5e92f37 100644
--- a/InvenTree/plugin/mixins/__init__.py
+++ b/InvenTree/plugin/mixins/__init__.py
@@ -2,7 +2,8 @@
Utility class to enable simpler imports
"""
-from ..builtin.integration.mixins import APICallMixin, AppMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin
+from ..builtin.integration.mixins import APICallMixin, AppMixin, LabelPrintingMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin
+
from ..builtin.action.mixins import ActionMixin
from ..builtin.barcode.mixins import BarcodeMixin
@@ -10,6 +11,7 @@ __all__ = [
'APICallMixin',
'AppMixin',
'EventMixin',
+ 'LabelPrintingMixin',
'NavigationMixin',
'ScheduleMixin',
'SettingsMixin',
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 2db1beb805..3276c82f45 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -10,12 +10,13 @@ import pathlib
import logging
import os
import subprocess
+
from typing import OrderedDict
from importlib import reload
from django.apps import apps
from django.conf import settings
-from django.db.utils import OperationalError, ProgrammingError
+from django.db.utils import OperationalError, ProgrammingError, IntegrityError
from django.conf.urls import url, include
from django.urls import clear_url_caches
from django.contrib import admin
@@ -282,6 +283,8 @@ class PluginsRegistry:
if not settings.PLUGIN_TESTING:
raise error # pragma: no cover
plugin_db_setting = None
+ except (IntegrityError) as error:
+ logger.error(f"Error initializing plugin: {error}")
# Always activate if testing
if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active):
diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 5b416b4d22..46f7f32e42 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -213,7 +213,7 @@ function createBuildOutput(build_id, options) {
success: function(data) {
if (data.next) {
fields.serial_numbers.placeholder = `{% trans "Next available serial number" %}: ${data.next}`;
- } else {
+ } else if (data.latest) {
fields.serial_numbers.placeholder = `{% trans "Latest serial number" %}: ${data.latest}`;
}
},
diff --git a/InvenTree/templates/js/translated/label.js b/InvenTree/templates/js/translated/label.js
index 1c843917e6..5215ce9d28 100644
--- a/InvenTree/templates/js/translated/label.js
+++ b/InvenTree/templates/js/translated/label.js
@@ -14,11 +14,41 @@
*/
/* exported
+ printLabels,
printPartLabels,
printStockItemLabels,
printStockLocationLabels,
*/
+
+/*
+ * Perform the "print" action.
+ */
+function printLabels(url, plugin=null) {
+
+ if (plugin) {
+ // If a plugin is provided, do not redirect the browser.
+ // Instead, perform an API request and display a message
+
+ url = url + `plugin=${plugin}`;
+
+ inventreeGet(url, {}, {
+ success: function(response) {
+ showMessage(
+ '{% trans "Labels sent to printer" %}',
+ {
+ style: 'success',
+ }
+ );
+ }
+ });
+ } else {
+ window.location.href = url;
+ }
+
+}
+
+
function printStockItemLabels(items) {
/**
* Print stock item labels for the given stock items
@@ -57,14 +87,17 @@ function printStockItemLabels(items) {
response,
items,
{
- success: function(pk) {
+ success: function(data) {
+
+ var pk = data.label;
+
var href = `/api/label/stock/${pk}/print/?`;
items.forEach(function(item) {
href += `items[]=${item}&`;
});
- window.location.href = href;
+ printLabels(href, data.plugin);
}
}
);
@@ -73,6 +106,7 @@ function printStockItemLabels(items) {
);
}
+
function printStockLocationLabels(locations) {
if (locations.length == 0) {
@@ -107,14 +141,17 @@ function printStockLocationLabels(locations) {
response,
locations,
{
- success: function(pk) {
+ success: function(data) {
+
+ var pk = data.label;
+
var href = `/api/label/location/${pk}/print/?`;
locations.forEach(function(location) {
href += `locations[]=${location}&`;
});
- window.location.href = href;
+ printLabels(href, data.plugin);
}
}
);
@@ -162,14 +199,17 @@ function printPartLabels(parts) {
response,
parts,
{
- success: function(pk) {
- var url = `/api/label/part/${pk}/print/?`;
+ success: function(data) {
+
+ var pk = data.label;
+
+ var href = `/api/label/part/${pk}/print/?`;
parts.forEach(function(part) {
- url += `parts[]=${part}&`;
+ href += `parts[]=${part}&`;
});
- window.location.href = url;
+ printLabels(href, data.plugin);
}
}
);
@@ -188,18 +228,51 @@ function selectLabel(labels, items, options={}) {
* (via AJAX) from the server.
*/
- // If only a single label template is provided,
- // just run with that!
+ // Array of available plugins for label printing
+ var plugins = [];
- if (labels.length == 1) {
- if (options.success) {
- options.success(labels[0].pk);
+ // Request a list of available label printing plugins from the server
+ inventreeGet(
+ `/api/plugin/`,
+ {},
+ {
+ async: false,
+ success: function(response) {
+ response.forEach(function(plugin) {
+ // Look for active plugins which implement the 'labels' mixin class
+ if (plugin.active && plugin.mixins && plugin.mixins.labels) {
+ // This plugin supports label printing
+ plugins.push(plugin);
+ }
+ });
+ }
}
+ );
- return;
+ var plugin_selection = '';
+
+ if (plugins.length > 0) {
+ plugin_selection =`
+
+ `;
}
-
var modal = options.modal || '#modal-form';
var label_list = makeOptionsList(
@@ -233,14 +306,15 @@ function selectLabel(labels, items, options={}) {
`;
openModal({
@@ -255,14 +329,17 @@ function selectLabel(labels, items, options={}) {
modalSubmit(modal, function() {
- var label = $(modal).find('#id_label');
-
- var pk = label.val();
+ var label = $(modal).find('#id_label').val();
+ var plugin = $(modal).find('#id_plugin').val();
closeModal(modal);
if (options.success) {
- options.success(pk);
+ options.success({
+ // Return the selected label template and plugin
+ label: label,
+ plugin: plugin,
+ });
}
});
}