Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue2694

This commit is contained in:
Matthias 2022-03-27 23:19:41 +02:00
commit e1b3de001f
No known key found for this signature in database
GPG Key ID: F50EF5741D33E076
25 changed files with 489 additions and 259 deletions

View File

@ -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)

View File

@ -12,11 +12,17 @@ import common.models
INVENTREE_SW_VERSION = "0.7.0 dev"
# InvenTree API version
INVENTREE_API_VERSION = 32
INVENTREE_API_VERSION = 34
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v34 -> 2022-03-25
- Change permissions for "plugin list" API endpoint (now allows any authenticated user)
v33 -> 2022-03-24
- Adds "plugins_enabled" information to root API endpoint
v32 -> 2022-03-19
- Adds "parameters" detail to Part API endpoint (use &parameters=true)
- Adds ability to filter PartParameterTemplate API by Part instance

View File

@ -258,6 +258,19 @@
</a></li>
</ul>
</div>
<!-- Label Printing Actions -->
<div class='btn-group'>
<button id='output-print-options' class='btn btn-primary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Printing Actions" %}'>
<span class='fas fa-print'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
<li><a class='dropdown-item' href='#' id='incomplete-output-print-label' title='{% trans "Print labels" %}'>
<span class='fas fa-tags'></span> {% trans "Print labels" %}
</a></li>
</ul>
</div>
{% include "filter_list.html" with id='incompletebuilditems' %}
</div>
{% 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 %}

View File

@ -79,7 +79,7 @@ class BaseInvenTreeSetting(models.Model):
self.key = str(self.key).upper()
self.clean(**kwargs)
self.validate_unique()
self.validate_unique(**kwargs)
super().save()
@ -230,10 +230,6 @@ class BaseInvenTreeSetting(models.Model):
return choices
@classmethod
def get_filters(cls, key, **kwargs):
return {'key__iexact': key}
@classmethod
def get_setting_object(cls, key, **kwargs):
"""
@ -247,29 +243,35 @@ class BaseInvenTreeSetting(models.Model):
settings = cls.objects.all()
filters = {
'key__iexact': key,
}
# Filter by user
user = kwargs.get('user', None)
if user is not None:
settings = settings.filter(user=user)
filters['user'] = user
try:
setting = settings.filter(**cls.get_filters(key, **kwargs)).first()
except (ValueError, cls.DoesNotExist):
setting = None
except (IntegrityError, OperationalError):
setting = None
# Filter by plugin
plugin = kwargs.get('plugin', None)
plugin = kwargs.pop('plugin', None)
if plugin:
if plugin is not None:
from plugin import InvenTreePluginBase
if issubclass(plugin.__class__, InvenTreePluginBase):
plugin = plugin.plugin_config()
filters['plugin'] = plugin
kwargs['plugin'] = plugin
try:
setting = settings.filter(**filters).first()
except (ValueError, cls.DoesNotExist):
setting = None
except (IntegrityError, OperationalError):
setting = None
# Setting does not exist! (Try to create it)
if not setting:
@ -287,7 +289,7 @@ class BaseInvenTreeSetting(models.Model):
try:
# Wrap this statement in "atomic", so it can be rolled back if it fails
with transaction.atomic():
setting.save()
setting.save(**kwargs)
except (IntegrityError, OperationalError):
# It might be the case that the database isn't created yet
pass
@ -342,8 +344,26 @@ class BaseInvenTreeSetting(models.Model):
if change_user is not None and not change_user.is_staff:
return
filters = {
'key__iexact': key,
}
user = kwargs.get('user', None)
plugin = kwargs.get('plugin', None)
if user is not None:
filters['user'] = user
if plugin is not None:
from plugin import InvenTreePluginBase
if issubclass(plugin.__class__, InvenTreePluginBase):
filters['plugin'] = plugin.plugin_config()
else:
filters['plugin'] = plugin
try:
setting = cls.objects.get(**cls.get_filters(key, **kwargs))
setting = cls.objects.get(**filters)
except cls.DoesNotExist:
if create:
@ -438,17 +458,37 @@ class BaseInvenTreeSetting(models.Model):
validator(self.value)
def validate_unique(self, exclude=None, **kwargs):
""" Ensure that the key:value pair is unique.
"""
Ensure that the key:value pair is unique.
In addition to the base validators, this ensures that the 'key'
is unique, using a case-insensitive comparison.
Note that sub-classes (UserSetting, PluginSetting) use other filters
to determine if the setting is 'unique' or not
"""
super().validate_unique(exclude)
filters = {
'key__iexact': self.key,
}
user = getattr(self, 'user', None)
plugin = getattr(self, 'plugin', None)
if user is not None:
filters['user'] = user
if plugin is not None:
filters['plugin'] = plugin
try:
setting = self.__class__.objects.exclude(id=self.id).filter(**self.get_filters(self.key, **kwargs))
# Check if a duplicate setting already exists
setting = self.__class__.objects.filter(**filters).exclude(id=self.id)
if setting.exists():
raise ValidationError({'key': _('Key string must be unique')})
except self.DoesNotExist:
pass
@ -1200,6 +1240,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool,
},
'NOTIFICATION_SEND_EMAILS': {
'name': _('Enable email notifications'),
'description': _('Allow sending of emails for event notifications'),
'default': True,
'validator': bool,
},
"LABEL_INLINE": {
'name': _('Inline label display'),
'description': _('Display PDF labels in the browser, instead of downloading as a file'),
@ -1305,16 +1352,9 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
def get_setting_object(cls, key, user):
return super().get_setting_object(key, user=user)
def validate_unique(self, exclude=None):
def validate_unique(self, exclude=None, **kwargs):
return super().validate_unique(exclude=exclude, user=self.user)
@classmethod
def get_filters(cls, key, **kwargs):
return {
'key__iexact': key,
'user__id': kwargs['user'].id
}
def to_native_value(self):
"""
Return the "pythonic" value,

View File

@ -8,15 +8,19 @@ from allauth.account.models import EmailAddress
from InvenTree.helpers import inheritors
from InvenTree.ready import isImportingData
from common.models import NotificationEntry, NotificationMessage
from common.models import InvenTreeUserSetting
import InvenTree.tasks
logger = logging.getLogger('inventree')
# region notification classes
# region base classes
class NotificationMethod:
"""
Base class for notification methods
"""
METHOD_NAME = ''
CONTEXT_BUILTIN = ['name', 'message', ]
CONTEXT_EXTRA = []
@ -95,10 +99,8 @@ class SingleNotificationMethod(NotificationMethod):
class BulkNotificationMethod(NotificationMethod):
def send_bulk(self):
raise NotImplementedError('The `send` method must be overriden!')
# endregion
# region implementations
class EmailNotification(BulkNotificationMethod):
METHOD_NAME = 'mail'
CONTEXT_EXTRA = [
@ -108,13 +110,26 @@ class EmailNotification(BulkNotificationMethod):
]
def get_targets(self):
"""
Return a list of target email addresses,
only for users which allow email notifications
"""
allowed_users = []
for user in self.targets:
allows_emails = InvenTreeUserSetting.get_setting('NOTIFICATION_SEND_EMAILS', user=user)
if allows_emails:
allowed_users.append(user)
return EmailAddress.objects.filter(
user__in=self.targets,
user__in=allowed_users,
)
def send_bulk(self):
html_message = render_to_string(self.context['template']['html'], self.context)
targets = self.targets.values_list('email', flat=True)
targets = self.get_targets().values_list('email', flat=True)
InvenTree.tasks.send_email(self.context['template']['subject'], '', targets, html_message=html_message)
@ -137,20 +152,27 @@ class UIMessageNotification(SingleNotificationMethod):
message=self.context['message'],
)
return True
# endregion
# endregion
def trigger_notifaction(obj, category=None, obj_ref='pk', targets=None, target_fnc=None, target_args=[], target_kwargs={}, context={}):
def trigger_notifaction(obj, category=None, obj_ref='pk', **kwargs):
"""
Send out an notification
Send out a notification
"""
# check if data is importet currently
targets = kwargs.get('targets', None)
target_fnc = kwargs.get('target_fnc', None)
target_args = kwargs.get('target_args', [])
target_kwargs = kwargs.get('target_kwargs', {})
context = kwargs.get('context', {})
delivery_methods = kwargs.get('delivery_methods', None)
# Check if data is importing currently
if isImportingData():
return
# Resolve objekt reference
obj_ref_value = getattr(obj, obj_ref)
# Try with some defaults
if not obj_ref_value:
obj_ref_value = getattr(obj, 'pk')
@ -175,6 +197,7 @@ def trigger_notifaction(obj, category=None, obj_ref='pk', targets=None, target_f
logger.info(f"Sending notification '{category}' for '{str(obj)}'")
# Collect possible methods
if delivery_methods is None:
delivery_methods = inheritors(NotificationMethod)
for method in [a for a in delivery_methods if a not in [SingleNotificationMethod, BulkNotificationMethod]]:

View File

@ -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,51 @@ 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
"""
# Label instance
label_instance = self.get_object()
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,
label_instance=label_instance,
user=request.user,
)
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 +178,7 @@ class LabelPrintMixin:
html = "\n".join(outputs)
return HttpResponse(html)
else:
"""
Concatenate all rendered pages into a single PDF object,

View File

@ -13,6 +13,8 @@
body {
font-family: Arial, Helvetica, sans-serif;
margin: 0mm;
color: #000;
background-color: #FFF;
}
img {

View File

@ -5,7 +5,6 @@ import logging
from django.utils.translation import ugettext_lazy as _
import InvenTree.helpers
import InvenTree.tasks
import common.notifications

View File

@ -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
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)

View File

@ -9,6 +9,7 @@ from django.conf.urls import url, include
from rest_framework import generics
from rest_framework import status
from rest_framework import permissions
from rest_framework.response import Response
from common.api import GlobalSettingsPermissions
@ -22,6 +23,11 @@ class PluginList(generics.ListAPIView):
- GET: Return a list of all PluginConfig objects
"""
# Allow any logged in user to read this endpoint
# This is necessary to allow certain functionality,
# e.g. determining which label printing plugins are available
permission_classes = [permissions.IsAuthenticated]
serializer_class = PluginSerializers.PluginConfigSerializer
queryset = PluginConfig.objects.all()

View File

@ -393,6 +393,42 @@ 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
kwargs:
length: The length of the label (in mm)
width: The width of the label (in mm)
"""
# Unimplemented (to be implemented by the particular plugin class)
...
class APICallMixin:
"""
Mixin that enables easier API calls for a plugin

View File

@ -7,12 +7,15 @@ from __future__ import unicode_literals
import logging
from django.utils.translation import ugettext_lazy as _
from django.conf import settings
from django.db import transaction
from django.db.models.signals import post_save, post_delete
from django.dispatch.dispatcher import receiver
from common.models import InvenTreeSetting
import common.notifications
from InvenTree.ready import canAppAccessDatabase
from InvenTree.tasks import offload_task
@ -95,7 +98,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 +193,46 @@ def after_delete(sender, instance, **kwargs):
model=sender.__name__,
table=table,
)
def print_label(plugin_slug, label_image, label_instance=None, user=None):
"""
Print label with the provided plugin.
This task is nominally handled by the background worker.
If the printing fails (throws an exception) then the user is notified.
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
try:
plugin.print_label(label_image, width=label_instance.width, height=label_instance.height)
except Exception as e:
# Plugin threw an error - notify the user who attempted to print
ctx = {
'name': _('Label printing failed'),
'message': str(e),
}
logger.error(f"Label printing failed: Sending notification to user '{user}'")
# Throw an error against the plugin instance
common.notifications.trigger_notifaction(
plugin.plugin_config(),
'label.printing_failed',
targets=[user],
context=ctx,
delivery_methods=[common.notifications.UIMessageNotification]
)

View File

@ -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',

View File

@ -175,23 +175,6 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
return super().get_setting_definition(key, **kwargs)
@classmethod
def get_filters(cls, key, **kwargs):
"""
Override filters method to ensure settings are filtered by plugin id
"""
filters = super().get_filters(key, **kwargs)
plugin = kwargs.get('plugin', None)
if plugin:
if issubclass(plugin.__class__, InvenTreePluginBase):
plugin = plugin.plugin_config()
filters['plugin'] = plugin
return filters
plugin = models.ForeignKey(
PluginConfig,
related_name='settings',

View File

@ -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):

View File

@ -10,7 +10,7 @@
{% endblock %}
{% block actions %}
<div class='btn btn-secondary' type='button' id='mark-all' title='{% trans "Mark all as read" %}'>
<div class='btn btn-outline-secondary' type='button' id='mark-all' title='{% trans "Mark all as read" %}'>
<span class='fa fa-bookmark'></span> {% trans "Mark all as read" %}
</div>
<div class='btn btn-secondary' type='button' id='inbox-refresh' title='{% trans "Refresh Pending Notifications" %}'>

View File

@ -126,8 +126,12 @@ $("#mark-all").on('click', function() {
{
read: false,
},
);
{
success: function(response) {
updateNotificationTables();
}
}
);
});
loadNotificationTable("#history-table", {

View File

@ -22,6 +22,7 @@
{% include "InvenTree/settings/user_settings.html" %}
{% include "InvenTree/settings/user_homepage.html" %}
{% include "InvenTree/settings/user_search.html" %}
{% include "InvenTree/settings/user_notifications.html" %}
{% include "InvenTree/settings/user_labels.html" %}
{% include "InvenTree/settings/user_reports.html" %}
{% include "InvenTree/settings/user_display.html" %}

View File

@ -14,6 +14,8 @@
{% include "sidebar_item.html" with label='user-home' text=text icon="fa-home" %}
{% trans "Search Settings" as text %}
{% include "sidebar_item.html" with label='user-search' text=text icon="fa-search" %}
{% trans "Notifications" as text %}
{% include "sidebar_item.html" with label='user-notifications' text=text icon="fa-bell" %}
{% trans "Label Printing" as text %}
{% include "sidebar_item.html" with label='user-labels' text=text icon="fa-tag" %}
{% trans "Reporting" as text %}

View File

@ -0,0 +1,20 @@
{% extends "panel.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block label %}user-notifications{% endblock label %}
{% block heading %}{% trans "Notification Settings" %}{% endblock heading %}
{% block content %}
<div class='row'>
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="NOTIFICATION_SEND_EMAILS" icon='fa-envelope' user_setting=True %}
</tbody>
</table>
</div>
{% endblock content %}

View File

@ -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}`;
}
},
@ -1025,9 +1025,10 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
}
// Store the required quantity in the row data
row.required = quantity;
// Prevent weird rounding issues
row.required = parseFloat(quantity.toFixed(15));
return quantity;
return row.required;
}
function sumAllocations(row) {
@ -1043,9 +1044,9 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
quantity += item.quantity;
});
row.allocated = quantity;
row.allocated = parseFloat(quantity.toFixed(15));
return quantity;
return row.allocated;
}
function setupCallbacks() {
@ -1642,6 +1643,9 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
remaining = 0;
}
// Ensure the quantity sent to the form field is correctly formatted
remaining = parseFloat(remaining.toFixed(15));
// We only care about entries which are not yet fully allocated
if (remaining > 0) {
table_entries += renderBomItemRow(bom_item, remaining);

View File

@ -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,17 +228,50 @@ 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 =`
<div class='form-group'>
<label class='control-label requiredField' for='id_plugin'>
{% trans "Select Printer" %}
</label>
<div class='controls'>
<select id='id_plugin' class='select form-control' name='plugin'>
<option value='' title='{% trans "Export to PDF" %}'>{% trans "Export to PDF" %}</option>
`;
plugins.forEach(function(plugin) {
plugin_selection += `<option value='${plugin.key}' title='${plugin.meta.human_name}'>${plugin.name} - <small>${plugin.meta.human_name}</small></option>`;
});
plugin_selection += `
</select>
</div>
</div>
`;
}
var modal = options.modal || '#modal-form';
@ -233,14 +306,15 @@ function selectLabel(labels, items, options={}) {
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
<div class='form-group'>
<label class='control-label requiredField' for='id_label'>
{% trans "Select Label" %}
{% trans "Select Label Template" %}
</label>
<div class='controls'>
<select id='id_label' class='select form-control name='label'>
<select id='id_label' class='select form-control' name='label'>
${label_list}
</select>
</div>
</div>
${plugin_selection}
</form>`;
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,
});
}
});
}

View File

@ -253,7 +253,7 @@ function openNotificationPanel() {
{
success: function(response) {
if (response.length == 0) {
html = `<p class='text-muted'>{% trans "No unread notifications" %}</p>`;
html = `<p class='text-muted'><em>{% trans "No unread notifications" %}</em><span class='fas fa-check-circle icon-green float-right'></span></p>`;
} else {
// build up items
response.forEach(function(item, index) {

View File

@ -1770,6 +1770,7 @@ function loadStockTable(table, options) {
col = {
field: 'location_detail.pathstring',
title: '{% trans "Location" %}',
sortName: 'location',
formatter: function(value, row) {
return locationDetail(row);
}
@ -1912,172 +1913,8 @@ function loadStockTable(table, options) {
original: original,
showColumns: true,
columns: columns,
{% if False %}
groupByField: options.groupByField || 'part',
groupBy: grouping,
groupByFormatter: function(field, id, data) {
var row = data[0];
if (field == 'part_detail.full_name') {
var html = imageHoverIcon(row.part_detail.thumbnail);
html += row.part_detail.full_name;
html += ` <i>(${data.length} {% trans "items" %})</i>`;
html += makePartIcons(row.part_detail);
return html;
} else if (field == 'part_detail.IPN') {
var ipn = row.part_detail.IPN;
if (ipn) {
return ipn;
} else {
return '-';
}
} else if (field == 'part_detail.description') {
return row.part_detail.description;
} else if (field == 'packaging') {
var packaging = [];
data.forEach(function(item) {
var pkg = item.packaging;
if (!pkg) {
pkg = '-';
}
if (!packaging.includes(pkg)) {
packaging.push(pkg);
}
});
if (packaging.length > 1) {
return "...";
} else if (packaging.length == 1) {
return packaging[0];
} else {
return "-";
}
} else if (field == 'quantity') {
var stock = 0;
var items = 0;
data.forEach(function(item) {
stock += parseFloat(item.quantity);
items += 1;
});
stock = +stock.toFixed(5);
return `${stock} (${items} {% trans "items" %})`;
} else if (field == 'status') {
var statii = [];
data.forEach(function(item) {
var status = String(item.status);
if (!status || status == '') {
status = '-';
}
if (!statii.includes(status)) {
statii.push(status);
}
});
// Multiple status codes
if (statii.length > 1) {
return "...";
} else if (statii.length == 1) {
return stockStatusDisplay(statii[0]);
} else {
return "-";
}
} else if (field == 'batch') {
var batches = [];
data.forEach(function(item) {
var batch = item.batch;
if (!batch || batch == '') {
batch = '-';
}
if (!batches.includes(batch)) {
batches.push(batch);
}
});
if (batches.length > 1) {
return "" + batches.length + " {% trans 'batches' %}";
} else if (batches.length == 1) {
if (batches[0]) {
return batches[0];
} else {
return '-';
}
} else {
return '-';
}
} else if (field == 'location_detail.pathstring') {
/* Determine how many locations */
var locations = [];
data.forEach(function(item) {
var detail = locationDetail(item);
if (!locations.includes(detail)) {
locations.push(detail);
}
});
if (locations.length == 1) {
// Single location, easy!
return locations[0];
} else if (locations.length > 1) {
return "In " + locations.length + " {% trans 'locations' %}";
} else {
return "<i>{% trans 'Undefined location' %}</i>";
}
} else if (field == 'notes') {
var notes = [];
data.forEach(function(item) {
var note = item.notes;
if (!note || note == '') {
note = '-';
}
if (!notes.includes(note)) {
notes.push(note);
}
});
if (notes.length > 1) {
return '...';
} else if (notes.length == 1) {
return notes[0] || '-';
} else {
return '-';
}
} else {
return '';
}
},
{% endif %}
});
/*
if (options.buttons) {
linkButtonsToSelection(table, options.buttons);
}
*/
var buttons = [
'#stock-print-options',
'#stock-options',
@ -2092,7 +1929,6 @@ function loadStockTable(table, options) {
buttons,
);
function stockAdjustment(action) {
var items = $(table).bootstrapTable('getSelections');

View File

@ -1,7 +1,7 @@
# Base python requirements for docker containers
# Basic package requirements
setuptools>=57.4.0
setuptools>=57.4.0,<=60.1.0
wheel>=0.37.0
invoke>=1.4.0 # Invoke build tool